first commit
This commit is contained in:
340
packages/cloudflare/src/db/playground-middleware.ts
Normal file
340
packages/cloudflare/src/db/playground-middleware.ts
Normal file
@@ -0,0 +1,340 @@
|
||||
/**
|
||||
* Playground middleware — injected by the EmDash integration as order: "pre".
|
||||
*
|
||||
* Runs BEFORE the EmDash runtime init middleware. Creates a per-session
|
||||
* Durable Object database, runs migrations, applies the seed, creates an
|
||||
* anonymous admin user, and sets the DB in ALS via runWithContext().
|
||||
*
|
||||
* By the time the runtime middleware runs, the ALS-scoped DB is ready.
|
||||
* The runtime's `db` getter checks ALS first, so all init queries
|
||||
* (migrations, FTS, cron, manifest) operate on the real DO database.
|
||||
*
|
||||
* This module is registered via `addMiddleware({ entrypoint: "..." })` in
|
||||
* the integration, NOT in the user's src/middleware.ts.
|
||||
*/
|
||||
|
||||
import { defineMiddleware } from "astro:middleware";
|
||||
import { env } from "cloudflare:workers";
|
||||
import { Kysely, sql } from "kysely";
|
||||
import { ulid } from "ulidx";
|
||||
// @ts-ignore - virtual module populated by EmDash integration at build time
|
||||
import virtualConfig from "virtual:emdash/config";
|
||||
|
||||
import type { EmDashPreviewDB } from "./do-class.js";
|
||||
import { PreviewDODialect } from "./do-dialect.js";
|
||||
import type { PreviewDBStub } from "./do-dialect.js";
|
||||
import { isBlockedInPlayground } from "./do-playground-routes.js";
|
||||
import { renderPlaygroundToolbar } from "./playground-toolbar.js";
|
||||
|
||||
/** Default TTL for playground data (1 hour) */
|
||||
const DEFAULT_TTL = 3600;
|
||||
|
||||
/** Cookie name for playground session */
|
||||
const COOKIE_NAME = "emdash_playground";
|
||||
|
||||
/** Playground admin user constants */
|
||||
const PLAYGROUND_USER_ID = "playground-admin";
|
||||
const PLAYGROUND_USER_EMAIL = "playground@emdashcms.com";
|
||||
const PLAYGROUND_USER_NAME = "Playground User";
|
||||
const PLAYGROUND_USER_ROLE = 50; // Admin
|
||||
|
||||
const PLAYGROUND_USER = {
|
||||
id: PLAYGROUND_USER_ID,
|
||||
email: PLAYGROUND_USER_EMAIL,
|
||||
name: PLAYGROUND_USER_NAME,
|
||||
role: PLAYGROUND_USER_ROLE,
|
||||
};
|
||||
|
||||
/** Track which DOs have been initialized this Worker lifetime */
|
||||
const initializedSessions = new Set<string>();
|
||||
|
||||
/**
|
||||
* Read the DO binding name from the virtual config.
|
||||
* The database config has the binding in `config.database.config.binding`.
|
||||
*/
|
||||
function getBindingName(): string {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import
|
||||
const config = virtualConfig as { database?: { config?: { binding?: string } } } | null;
|
||||
const binding = config?.database?.config?.binding;
|
||||
if (!binding) {
|
||||
throw new Error(
|
||||
"Playground middleware: no database binding found in config. " +
|
||||
"Ensure database: playgroundDatabase({ binding: '...' }) is set.",
|
||||
);
|
||||
}
|
||||
return binding;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a PreviewDBStub for the given session token.
|
||||
*/
|
||||
function getStub(binding: string, token: string): PreviewDBStub {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
|
||||
const ns = (env as Record<string, unknown>)[binding];
|
||||
if (!ns) {
|
||||
throw new Error(`Playground binding "${binding}" not found in environment`);
|
||||
}
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
|
||||
const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
|
||||
const doId = namespace.idFromName(token);
|
||||
const stub = namespace.get(doId);
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- RPC type limitation
|
||||
return stub as unknown as PreviewDBStub;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full DO stub for direct RPC calls (e.g. setTtlAlarm).
|
||||
*/
|
||||
function getFullStub(binding: string, token: string): DurableObjectStub<EmDashPreviewDB> {
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
|
||||
const ns = (env as Record<string, unknown>)[binding];
|
||||
if (!ns) {
|
||||
throw new Error(`Playground binding "${binding}" not found in environment`);
|
||||
}
|
||||
// eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
|
||||
const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
|
||||
const doId = namespace.idFromName(token);
|
||||
return namespace.get(doId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a created-at timestamp from the ULID session token.
|
||||
*/
|
||||
function getSessionCreatedAt(token: string): string {
|
||||
try {
|
||||
const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
|
||||
let time = 0;
|
||||
const chars = token.toUpperCase().slice(0, 10);
|
||||
for (const char of chars) {
|
||||
time = time * 32 + ENCODING.indexOf(char);
|
||||
}
|
||||
return new Date(time).toISOString();
|
||||
} catch {
|
||||
return new Date().toISOString();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize a playground DO: run migrations, apply seed, create admin user.
|
||||
*/
|
||||
async function initializePlayground(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
db: Kysely<any>,
|
||||
token: string,
|
||||
): Promise<void> {
|
||||
// Check if already initialized (persisted in the DO)
|
||||
try {
|
||||
const { rows } = await sql<{ value: string }>`
|
||||
SELECT value FROM options WHERE name = ${"emdash:setup_complete"}
|
||||
`.execute(db);
|
||||
|
||||
if (rows.length > 0) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// Table doesn't exist yet -- first initialization
|
||||
}
|
||||
|
||||
console.log(`[playground] Initializing session ${token}`);
|
||||
|
||||
// 1. Run all EmDash migrations.
|
||||
// If the DO was previously initialized (persisted state) but somehow the
|
||||
// setup_complete flag is missing, migrations may partially fail on tables
|
||||
// that already exist. Treat migration errors as non-fatal if there are
|
||||
// tables present (i.e. the DO was previously initialized).
|
||||
const { runMigrations } = await import("emdash/db");
|
||||
try {
|
||||
const migrations = await runMigrations(db);
|
||||
console.log(`[playground] Migrations applied: ${migrations.applied.length}`);
|
||||
} catch (migrationError) {
|
||||
// Check if this looks like a "tables already exist" error -- the DO
|
||||
// was probably initialized in a previous Worker lifetime and the
|
||||
// options check above failed for a transient reason.
|
||||
const msg = migrationError instanceof Error ? migrationError.message : String(migrationError);
|
||||
if (msg.includes("already exists")) {
|
||||
console.log(`[playground] Migrations skipped (tables already exist)`);
|
||||
// Mark setup complete if it wasn't (recover from partial init)
|
||||
try {
|
||||
await sql`
|
||||
INSERT OR IGNORE INTO options (name, value)
|
||||
VALUES (${"emdash:setup_complete"}, ${JSON.stringify(true)})
|
||||
`.execute(db);
|
||||
} catch {
|
||||
// Best effort
|
||||
}
|
||||
return;
|
||||
}
|
||||
throw migrationError;
|
||||
}
|
||||
|
||||
// 2. Load and apply seed with content (skip media downloads)
|
||||
const { loadSeed } = await import("emdash/seed");
|
||||
const { applySeed } = await import("emdash");
|
||||
const seed = await loadSeed();
|
||||
const seedResult = await applySeed(db, seed, {
|
||||
includeContent: true,
|
||||
onConflict: "skip",
|
||||
skipMediaDownload: true,
|
||||
});
|
||||
console.log(
|
||||
`[playground] Seed applied: ${seedResult.collections.created} collections, ${seedResult.content.created} content entries`,
|
||||
);
|
||||
|
||||
// 3. Create anonymous admin user
|
||||
const now = new Date().toISOString();
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO users (id, email, name, role, email_verified, created_at, updated_at)
|
||||
VALUES (${PLAYGROUND_USER_ID}, ${PLAYGROUND_USER_EMAIL}, ${PLAYGROUND_USER_NAME},
|
||||
${PLAYGROUND_USER_ROLE}, ${1}, ${now}, ${now})
|
||||
`.execute(db);
|
||||
} catch {
|
||||
// User might already exist
|
||||
}
|
||||
|
||||
// 4. Mark setup complete
|
||||
try {
|
||||
await sql`
|
||||
INSERT INTO options (name, value)
|
||||
VALUES (${"emdash:setup_complete"}, ${JSON.stringify(true)})
|
||||
`.execute(db);
|
||||
} catch {
|
||||
// May already exist
|
||||
}
|
||||
|
||||
// 5. Set site title
|
||||
try {
|
||||
await sql`
|
||||
INSERT OR REPLACE INTO options (name, value)
|
||||
VALUES (${"emdash:site_title"}, ${JSON.stringify("EmDash Playground")})
|
||||
`.execute(db);
|
||||
} catch {
|
||||
// Non-critical
|
||||
}
|
||||
|
||||
console.log(`[playground] Session ${token} initialized`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject playground toolbar HTML into an HTML response.
|
||||
*/
|
||||
async function injectPlaygroundToolbar(
|
||||
response: Response,
|
||||
config: { createdAt: string; ttl: number; editMode: boolean },
|
||||
): Promise<Response> {
|
||||
const contentType = response.headers.get("content-type");
|
||||
if (!contentType?.includes("text/html")) return response;
|
||||
|
||||
const html = await response.text();
|
||||
if (!html.includes("</body>")) return new Response(html, response);
|
||||
|
||||
const toolbarHtml = renderPlaygroundToolbar(config);
|
||||
const injected = html.replace("</body>", `${toolbarHtml}</body>`);
|
||||
return new Response(injected, {
|
||||
status: response.status,
|
||||
headers: response.headers,
|
||||
});
|
||||
}
|
||||
|
||||
export const onRequest = defineMiddleware(async (context, next) => {
|
||||
const { url, cookies } = context;
|
||||
const ttl = DEFAULT_TTL;
|
||||
|
||||
// Lazy-load binding name from virtual config
|
||||
const binding = getBindingName();
|
||||
|
||||
// --- Entry point: /playground ---
|
||||
if (url.pathname === "/playground") {
|
||||
let token = cookies.get(COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
token = ulid();
|
||||
cookies.set(COOKIE_NAME, token, {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: ttl,
|
||||
});
|
||||
}
|
||||
|
||||
const stub = getStub(binding, token);
|
||||
const dialect = new PreviewDODialect({ getStub: () => stub });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = new Kysely<any>({ dialect });
|
||||
|
||||
if (!initializedSessions.has(token)) {
|
||||
await initializePlayground(db, token);
|
||||
initializedSessions.add(token);
|
||||
const fullStub = getFullStub(binding, token);
|
||||
await fullStub.setTtlAlarm(ttl);
|
||||
}
|
||||
|
||||
return context.redirect("/_emdash/admin");
|
||||
}
|
||||
|
||||
// --- Reset endpoint ---
|
||||
// Instead of dropping tables on the old DO (which is fragile and races
|
||||
// with cached state), just clear the cookie and redirect to /playground.
|
||||
// That creates a brand new DO with a fresh session -- clean slate.
|
||||
// The old DO expires via its TTL alarm.
|
||||
if (url.pathname === "/_playground/reset") {
|
||||
cookies.delete(COOKIE_NAME, { path: "/" });
|
||||
return context.redirect("/playground");
|
||||
}
|
||||
|
||||
// --- Route gating ---
|
||||
if (isBlockedInPlayground(url.pathname)) {
|
||||
return Response.json(
|
||||
{ error: { code: "PLAYGROUND_MODE", message: "Not available in playground mode" } },
|
||||
{ status: 403 },
|
||||
);
|
||||
}
|
||||
|
||||
// --- Resolve session ---
|
||||
const token = cookies.get(COOKIE_NAME)?.value;
|
||||
if (!token) {
|
||||
// No session -- redirect to /playground to create one
|
||||
return context.redirect("/playground");
|
||||
}
|
||||
|
||||
// --- Set up DO database and ALS ---
|
||||
const stub = getStub(binding, token);
|
||||
const dialect = new PreviewDODialect({ getStub: () => stub });
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const db = new Kysely<any>({ dialect });
|
||||
|
||||
// Ensure initialized
|
||||
if (!initializedSessions.has(token)) {
|
||||
try {
|
||||
await initializePlayground(db, token);
|
||||
initializedSessions.add(token);
|
||||
const fullStub = getFullStub(binding, token);
|
||||
await fullStub.setTtlAlarm(ttl);
|
||||
} catch (error) {
|
||||
console.error("Playground initialization failed:", error);
|
||||
return Response.json(
|
||||
{ error: { code: "PLAYGROUND_INIT_ERROR", message: "Failed to initialize playground" } },
|
||||
{ status: 500 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Stash the DO database and user on locals so downstream middleware
|
||||
// (runtime init, request-context) can use them. We can't use ALS directly
|
||||
// because this middleware is in @emdashcms/cloudflare and resolves to a
|
||||
// different AsyncLocalStorage instance than the emdash core package
|
||||
// (workerd loads dist modules separately from Vite's source modules).
|
||||
// The request-context middleware (same module context as the loader)
|
||||
// detects locals.__playgroundDb and wraps the render in runWithContext().
|
||||
// The __playgroundDb property is declared on App.Locals in emdash's locals.d.ts.
|
||||
Object.assign(context.locals, { __playgroundDb: db, user: PLAYGROUND_USER });
|
||||
|
||||
const editMode = cookies.get("emdash-edit-mode")?.value === "true";
|
||||
|
||||
const response = await next();
|
||||
|
||||
return injectPlaygroundToolbar(response, {
|
||||
createdAt: getSessionCreatedAt(token),
|
||||
ttl,
|
||||
editMode,
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user