341 lines
11 KiB
TypeScript
341 lines
11 KiB
TypeScript
/**
|
|
* 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 @emdash-cms/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,
|
|
});
|
|
});
|