@emdash-cms/cloudflare 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/dist/auth/index.d.mts +81 -0
  2. package/dist/auth/index.mjs +147 -0
  3. package/dist/cache/config.d.mts +52 -0
  4. package/dist/cache/config.mjs +55 -0
  5. package/dist/cache/runtime.d.mts +40 -0
  6. package/dist/cache/runtime.mjs +191 -0
  7. package/dist/d1-introspector-bZf0_ylK.mjs +57 -0
  8. package/dist/db/d1.d.mts +43 -0
  9. package/dist/db/d1.mjs +74 -0
  10. package/dist/db/do.d.mts +96 -0
  11. package/dist/db/do.mjs +489 -0
  12. package/dist/db/playground-middleware.d.mts +20 -0
  13. package/dist/db/playground-middleware.mjs +533 -0
  14. package/dist/db/playground.d.mts +39 -0
  15. package/dist/db/playground.mjs +26 -0
  16. package/dist/do-class-DY2Ba2RJ.mjs +174 -0
  17. package/dist/do-class-x5Xh_G62.d.mts +73 -0
  18. package/dist/do-dialect-BhFcRSFQ.mjs +58 -0
  19. package/dist/do-playground-routes-CmwFeGwJ.mjs +49 -0
  20. package/dist/do-types-CY0G0oyh.d.mts +14 -0
  21. package/dist/images-4RT9Ag8_.d.mts +76 -0
  22. package/dist/index.d.mts +200 -0
  23. package/dist/index.mjs +214 -0
  24. package/dist/media/images-runtime.d.mts +10 -0
  25. package/dist/media/images-runtime.mjs +215 -0
  26. package/dist/media/stream-runtime.d.mts +10 -0
  27. package/dist/media/stream-runtime.mjs +218 -0
  28. package/dist/plugins/index.d.mts +32 -0
  29. package/dist/plugins/index.mjs +163 -0
  30. package/dist/sandbox/index.d.mts +255 -0
  31. package/dist/sandbox/index.mjs +945 -0
  32. package/dist/storage/r2.d.mts +31 -0
  33. package/dist/storage/r2.mjs +116 -0
  34. package/dist/stream-DdbcvKi0.d.mts +78 -0
  35. package/package.json +109 -0
  36. package/src/auth/cloudflare-access.ts +303 -0
  37. package/src/auth/index.ts +16 -0
  38. package/src/cache/config.ts +81 -0
  39. package/src/cache/runtime.ts +328 -0
  40. package/src/cloudflare.d.ts +31 -0
  41. package/src/db/d1-introspector.ts +120 -0
  42. package/src/db/d1.ts +112 -0
  43. package/src/db/do-class.ts +275 -0
  44. package/src/db/do-dialect.ts +125 -0
  45. package/src/db/do-playground-routes.ts +65 -0
  46. package/src/db/do-preview-routes.ts +48 -0
  47. package/src/db/do-preview-sign.ts +100 -0
  48. package/src/db/do-preview.ts +268 -0
  49. package/src/db/do-types.ts +12 -0
  50. package/src/db/do.ts +62 -0
  51. package/src/db/playground-middleware.ts +340 -0
  52. package/src/db/playground-toolbar.ts +341 -0
  53. package/src/db/playground.ts +49 -0
  54. package/src/db/preview-toolbar.ts +220 -0
  55. package/src/index.ts +285 -0
  56. package/src/media/images-runtime.ts +353 -0
  57. package/src/media/images.ts +114 -0
  58. package/src/media/stream-runtime.ts +392 -0
  59. package/src/media/stream.ts +118 -0
  60. package/src/plugins/index.ts +7 -0
  61. package/src/plugins/vectorize-search.ts +393 -0
  62. package/src/sandbox/bridge.ts +1008 -0
  63. package/src/sandbox/index.ts +13 -0
  64. package/src/sandbox/runner.ts +357 -0
  65. package/src/sandbox/types.ts +181 -0
  66. package/src/sandbox/wrapper.ts +238 -0
  67. package/src/storage/r2.ts +200 -0
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Preview middleware for Durable Object-backed preview databases.
3
+ *
4
+ * This middleware intercepts requests to a preview service, validates
5
+ * signed preview URLs, creates/resolves DO sessions, populates snapshots,
6
+ * and overrides the request-context DB so all queries route to the
7
+ * isolated DO database.
8
+ *
9
+ * Designed to be registered as Astro middleware in a preview Worker.
10
+ *
11
+ * @example
12
+ * ```ts
13
+ * // src/middleware.ts (in the preview Worker)
14
+ * import { createPreviewMiddleware } from "@emdash-cms/cloudflare/db/do";
15
+ *
16
+ * export const onRequest = createPreviewMiddleware({
17
+ * binding: "PREVIEW_DB",
18
+ * secret: import.meta.env.PREVIEW_SECRET,
19
+ * });
20
+ * ```
21
+ */
22
+
23
+ import type { MiddlewareHandler } from "astro";
24
+ import { env } from "cloudflare:workers";
25
+ import { Kysely } from "kysely";
26
+ import { runWithContext } from "emdash/request-context";
27
+ import { ulid } from "ulidx";
28
+
29
+ import type { EmDashPreviewDB } from "./do-class.js";
30
+ import { PreviewDODialect } from "./do-dialect.js";
31
+ import type { PreviewDBStub } from "./do-dialect.js";
32
+ import { isBlockedInPreview } from "./do-preview-routes.js";
33
+ import { verifyPreviewSignature } from "./do-preview-sign.js";
34
+ import { renderPreviewToolbar } from "./preview-toolbar.js";
35
+
36
+ /** Configuration for the preview middleware */
37
+ export interface PreviewMiddlewareConfig {
38
+ /** Durable Object binding name (from wrangler.jsonc) */
39
+ binding: string;
40
+ /** HMAC secret for validating signed preview URLs */
41
+ secret: string;
42
+ /** TTL for preview data in seconds (default: 3600 = 1 hour) */
43
+ ttl?: number;
44
+ /** Cookie name for session token (default: "emdash_preview") */
45
+ cookieName?: string;
46
+ }
47
+
48
+ /**
49
+ * Simple loading interstitial HTML.
50
+ * Auto-reloads after a short delay to check if the snapshot is ready.
51
+ */
52
+ function loadingPage(): string {
53
+ return `<!DOCTYPE html>
54
+ <html lang="en">
55
+ <head>
56
+ <meta charset="utf-8">
57
+ <meta name="viewport" content="width=device-width, initial-scale=1">
58
+ <meta http-equiv="refresh" content="2">
59
+ <title>Loading preview...</title>
60
+ <style>
61
+ body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fafafa; color: #333; }
62
+ .spinner { width: 40px; height: 40px; border: 3px solid #e0e0e0; border-top-color: #333; border-radius: 50%; animation: spin 0.8s linear infinite; margin-right: 16px; }
63
+ @keyframes spin { to { transform: rotate(360deg); } }
64
+ </style>
65
+ </head>
66
+ <body>
67
+ <div class="spinner"></div>
68
+ <p>Loading preview&hellip;</p>
69
+ </body>
70
+ </html>`;
71
+ }
72
+
73
+ /**
74
+ * Create an Astro-compatible preview middleware.
75
+ *
76
+ * Returns a middleware function that can be used in `defineMiddleware()`
77
+ * or composed via `sequence()`.
78
+ */
79
+ export function createPreviewMiddleware(config: PreviewMiddlewareConfig): MiddlewareHandler {
80
+ const { binding, secret, ttl = 3600, cookieName = "emdash_preview" } = config;
81
+
82
+ return async function previewMiddleware(context, next) {
83
+ const { url, cookies } = context;
84
+
85
+ // --- 0a. Reload endpoint ---
86
+ // The toolbar POSTs here to clear the httpOnly session cookie and
87
+ // redirect back with the original signed params for a fresh snapshot.
88
+ if (url.pathname === "/_preview/reload") {
89
+ cookies.delete(cookieName, { path: "/" });
90
+ let redirectTo = "/";
91
+ const paramsCookie = cookies.get(`${cookieName}_params`)?.value;
92
+ if (paramsCookie) {
93
+ const parts = decodeURIComponent(paramsCookie).split("\n");
94
+ if (parts.length === 3) {
95
+ const reloadUrl = new URL("/", url.origin);
96
+ reloadUrl.searchParams.set("source", parts[0]!);
97
+ reloadUrl.searchParams.set("exp", parts[1]!);
98
+ reloadUrl.searchParams.set("sig", parts[2]!);
99
+ redirectTo = reloadUrl.pathname + reloadUrl.search;
100
+ }
101
+ }
102
+ return context.redirect(redirectTo);
103
+ }
104
+
105
+ // --- 0b. Route gating ---
106
+ // Block admin UI, auth, and setup routes. These depend on state
107
+ // (users, sessions, credentials) that doesn't exist in preview snapshots.
108
+ if (isBlockedInPreview(url.pathname)) {
109
+ return Response.json(
110
+ { error: { code: "PREVIEW_MODE", message: "Not available in preview mode" } },
111
+ { status: 403 },
112
+ );
113
+ }
114
+
115
+ // --- 1. Resolve session token ---
116
+ let sessionToken: string | undefined = cookies.get(cookieName)?.value;
117
+ let sourceUrl: string | null = null;
118
+ let snapshotSignature: string | null = null;
119
+
120
+ if (!sessionToken) {
121
+ // No cookie — must have a signed URL
122
+ const source = url.searchParams.get("source");
123
+ const exp = url.searchParams.get("exp");
124
+ const sig = url.searchParams.get("sig");
125
+
126
+ if (!source || !exp || !sig) {
127
+ return new Response("Missing preview parameters", { status: 400 });
128
+ }
129
+
130
+ const expNum = parseInt(exp, 10);
131
+ if (isNaN(expNum) || expNum < Date.now() / 1000) {
132
+ return new Response("Preview link expired", { status: 403 });
133
+ }
134
+
135
+ const valid = await verifyPreviewSignature(source, expNum, sig, secret);
136
+ if (!valid) {
137
+ return new Response("Invalid preview signature", { status: 403 });
138
+ }
139
+
140
+ // Generate session
141
+ sessionToken = ulid();
142
+ sourceUrl = source;
143
+ // Build the signature header value for snapshot fetch: "source:exp:sig"
144
+ snapshotSignature = `${source}:${exp}:${sig}`;
145
+
146
+ cookies.set(cookieName, sessionToken, {
147
+ httpOnly: true,
148
+ sameSite: "lax",
149
+ path: "/",
150
+ maxAge: ttl,
151
+ });
152
+ // Store the signed params so the toolbar can trigger a reload.
153
+ // Not httpOnly — the toolbar script needs to read them.
154
+ cookies.set(`${cookieName}_params`, `${source}\n${exp}\n${sig}`, {
155
+ sameSite: "lax",
156
+ path: "/",
157
+ maxAge: ttl,
158
+ });
159
+ }
160
+
161
+ // --- 2. Get DO stub ---
162
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
163
+ const ns = (env as Record<string, unknown>)[binding];
164
+ if (!ns) {
165
+ console.error(`Preview binding "${binding}" not found in environment`);
166
+ return new Response("Preview service misconfigured", { status: 500 });
167
+ }
168
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
169
+ const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
170
+ const doId = namespace.idFromName(sessionToken);
171
+ const stub = namespace.get(doId);
172
+
173
+ // --- 3. Populate from snapshot if needed ---
174
+ let snapshotGeneratedAt: string | undefined;
175
+ let snapshotError: string | undefined;
176
+
177
+ if (!sourceUrl) {
178
+ // Returning session — get metadata from the DO
179
+ try {
180
+ const meta = await stub.getSnapshotMeta();
181
+ snapshotGeneratedAt = meta?.generatedAt;
182
+ } catch {
183
+ // DO may have expired or been cleaned up
184
+ }
185
+ }
186
+
187
+ if (sourceUrl && snapshotSignature) {
188
+ try {
189
+ // Pass the full signature header value (source:exp:sig) so the DO
190
+ // can send it as X-Preview-Signature when fetching the snapshot.
191
+ const result = await stub.populateFromSnapshot(sourceUrl, snapshotSignature, { ttl });
192
+ snapshotGeneratedAt = result.generatedAt;
193
+
194
+ // Snapshot loaded — redirect to strip signed params from the URL.
195
+ // Astro's cookie buffer flushes on context.redirect().
196
+ const cleanUrl = new URL(url);
197
+ cleanUrl.searchParams.delete("source");
198
+ cleanUrl.searchParams.delete("exp");
199
+ cleanUrl.searchParams.delete("sig");
200
+ return context.redirect(cleanUrl.pathname + cleanUrl.search);
201
+ } catch (error) {
202
+ const message = error instanceof Error ? error.message : String(error);
203
+ console.error("Failed to populate preview snapshot:", message);
204
+ snapshotError = message;
205
+
206
+ // If this is the initial load (no session yet), show a loading page.
207
+ // If we already have a session, continue with stale data and show the error in the toolbar.
208
+ if (!cookies.get(cookieName)?.value) {
209
+ return new Response(loadingPage(), {
210
+ status: 503,
211
+ headers: {
212
+ "Content-Type": "text/html",
213
+ "Retry-After": "2",
214
+ },
215
+ });
216
+ }
217
+ }
218
+ }
219
+
220
+ // --- 4. Create Kysely dialect pointing at the DO ---
221
+ const getStub = (): PreviewDBStub => {
222
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- RPC type limitation
223
+ return stub as unknown as PreviewDBStub;
224
+ };
225
+ const dialect = new PreviewDODialect({ getStub });
226
+
227
+ // --- 5. Create Kysely instance and override request-context DB ---
228
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
229
+ const previewDb = new Kysely<any>({ dialect });
230
+
231
+ return runWithContext(
232
+ {
233
+ editMode: false,
234
+ db: previewDb,
235
+ },
236
+ async () => {
237
+ const response = await next();
238
+ return injectPreviewToolbar(response, {
239
+ generatedAt: snapshotGeneratedAt,
240
+ source: sourceUrl ?? undefined,
241
+ error: snapshotError,
242
+ });
243
+ },
244
+ );
245
+ };
246
+ }
247
+
248
+ /**
249
+ * Inject preview toolbar HTML into an HTML response.
250
+ * Returns the original response unchanged for non-HTML responses.
251
+ */
252
+ async function injectPreviewToolbar(
253
+ response: Response,
254
+ config: { generatedAt?: string; source?: string; error?: string },
255
+ ): Promise<Response> {
256
+ const contentType = response.headers.get("content-type");
257
+ if (!contentType?.includes("text/html")) return response;
258
+
259
+ const html = await response.text();
260
+ if (!html.includes("</body>")) return new Response(html, response);
261
+
262
+ const toolbarHtml = renderPreviewToolbar(config);
263
+ const injected = html.replace("</body>", `${toolbarHtml}</body>`);
264
+ return new Response(injected, {
265
+ status: response.status,
266
+ headers: response.headers,
267
+ });
268
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Shared Durable Object config types (preview-only)
3
+ *
4
+ * Imported by both the config-time entry (index.ts) and the runtime entry (do.ts).
5
+ * This module must NOT import from cloudflare:workers so it stays safe at config time.
6
+ */
7
+
8
+ /** Durable Object preview database configuration */
9
+ export interface PreviewDOConfig {
10
+ /** Wrangler binding name for the DO namespace */
11
+ binding: string;
12
+ }
package/src/db/do.ts ADDED
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Durable Object preview database — RUNTIME ENTRY
3
+ *
4
+ * Creates a Kysely dialect backed by a preview Durable Object.
5
+ * Loaded at runtime via virtual module when preview database queries are needed.
6
+ *
7
+ * This module imports directly from cloudflare:workers to access the DO binding.
8
+ * Do NOT import this at config time.
9
+ */
10
+
11
+ import { env } from "cloudflare:workers";
12
+ import type { Dialect } from "kysely";
13
+
14
+ import type { EmDashPreviewDB } from "./do-class.js";
15
+ import { PreviewDODialect } from "./do-dialect.js";
16
+ import type { PreviewDBStub } from "./do-dialect.js";
17
+ import type { PreviewDOConfig } from "./do-types.js";
18
+
19
+ /**
20
+ * Create a preview DO dialect from config.
21
+ *
22
+ * The caller is responsible for resolving the DO name (session token).
23
+ * This is passed as `config.name` by the preview middleware.
24
+ */
25
+ export function createDialect(config: PreviewDOConfig & { name: string }): Dialect {
26
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding accessed from untyped env object
27
+ const ns = (env as Record<string, unknown>)[config.binding];
28
+
29
+ if (!ns) {
30
+ throw new Error(
31
+ `Durable Object binding "${config.binding}" not found in environment. ` +
32
+ `Check your wrangler.jsonc configuration:\n\n` +
33
+ `[durable_objects]\n` +
34
+ `bindings = [\n` +
35
+ ` { name = "${config.binding}", class_name = "EmDashPreviewDB" }\n` +
36
+ `]\n\n` +
37
+ `[[migrations]]\n` +
38
+ `tag = "v1"\n` +
39
+ `new_sqlite_classes = ["EmDashPreviewDB"]`,
40
+ );
41
+ }
42
+
43
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace binding from untyped env object
44
+ const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
45
+ const id = namespace.idFromName(config.name);
46
+
47
+ // Return a factory that creates a fresh stub per connection.
48
+ const getStub = (): PreviewDBStub => {
49
+ const stub = namespace.get(id);
50
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Rpc type limitation with unknown in return types
51
+ return stub as unknown as PreviewDBStub;
52
+ };
53
+
54
+ return new PreviewDODialect({ getStub });
55
+ }
56
+
57
+ // Re-export the DO class and preview middleware for user convenience
58
+ export { EmDashPreviewDB } from "./do-class.js";
59
+ export { createPreviewMiddleware } from "./do-preview.js";
60
+ export type { PreviewMiddlewareConfig } from "./do-preview.js";
61
+ export { isBlockedInPreview } from "./do-preview-routes.js";
62
+ export { signPreviewUrl, verifyPreviewSignature } from "./do-preview-sign.js";
@@ -0,0 +1,340 @@
1
+ /**
2
+ * Playground middleware — injected by the EmDash integration as order: "pre".
3
+ *
4
+ * Runs BEFORE the EmDash runtime init middleware. Creates a per-session
5
+ * Durable Object database, runs migrations, applies the seed, creates an
6
+ * anonymous admin user, and sets the DB in ALS via runWithContext().
7
+ *
8
+ * By the time the runtime middleware runs, the ALS-scoped DB is ready.
9
+ * The runtime's `db` getter checks ALS first, so all init queries
10
+ * (migrations, FTS, cron, manifest) operate on the real DO database.
11
+ *
12
+ * This module is registered via `addMiddleware({ entrypoint: "..." })` in
13
+ * the integration, NOT in the user's src/middleware.ts.
14
+ */
15
+
16
+ import { defineMiddleware } from "astro:middleware";
17
+ import { env } from "cloudflare:workers";
18
+ import { Kysely, sql } from "kysely";
19
+ import { ulid } from "ulidx";
20
+ // @ts-ignore - virtual module populated by EmDash integration at build time
21
+ import virtualConfig from "virtual:emdash/config";
22
+
23
+ import type { EmDashPreviewDB } from "./do-class.js";
24
+ import { PreviewDODialect } from "./do-dialect.js";
25
+ import type { PreviewDBStub } from "./do-dialect.js";
26
+ import { isBlockedInPlayground } from "./do-playground-routes.js";
27
+ import { renderPlaygroundToolbar } from "./playground-toolbar.js";
28
+
29
+ /** Default TTL for playground data (1 hour) */
30
+ const DEFAULT_TTL = 3600;
31
+
32
+ /** Cookie name for playground session */
33
+ const COOKIE_NAME = "emdash_playground";
34
+
35
+ /** Playground admin user constants */
36
+ const PLAYGROUND_USER_ID = "playground-admin";
37
+ const PLAYGROUND_USER_EMAIL = "playground@emdashcms.com";
38
+ const PLAYGROUND_USER_NAME = "Playground User";
39
+ const PLAYGROUND_USER_ROLE = 50; // Admin
40
+
41
+ const PLAYGROUND_USER = {
42
+ id: PLAYGROUND_USER_ID,
43
+ email: PLAYGROUND_USER_EMAIL,
44
+ name: PLAYGROUND_USER_NAME,
45
+ role: PLAYGROUND_USER_ROLE,
46
+ };
47
+
48
+ /** Track which DOs have been initialized this Worker lifetime */
49
+ const initializedSessions = new Set<string>();
50
+
51
+ /**
52
+ * Read the DO binding name from the virtual config.
53
+ * The database config has the binding in `config.database.config.binding`.
54
+ */
55
+ function getBindingName(): string {
56
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- virtual module import
57
+ const config = virtualConfig as { database?: { config?: { binding?: string } } } | null;
58
+ const binding = config?.database?.config?.binding;
59
+ if (!binding) {
60
+ throw new Error(
61
+ "Playground middleware: no database binding found in config. " +
62
+ "Ensure database: playgroundDatabase({ binding: '...' }) is set.",
63
+ );
64
+ }
65
+ return binding;
66
+ }
67
+
68
+ /**
69
+ * Get a PreviewDBStub for the given session token.
70
+ */
71
+ function getStub(binding: string, token: string): PreviewDBStub {
72
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
73
+ const ns = (env as Record<string, unknown>)[binding];
74
+ if (!ns) {
75
+ throw new Error(`Playground binding "${binding}" not found in environment`);
76
+ }
77
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
78
+ const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
79
+ const doId = namespace.idFromName(token);
80
+ const stub = namespace.get(doId);
81
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- RPC type limitation
82
+ return stub as unknown as PreviewDBStub;
83
+ }
84
+
85
+ /**
86
+ * Get the full DO stub for direct RPC calls (e.g. setTtlAlarm).
87
+ */
88
+ function getFullStub(binding: string, token: string): DurableObjectStub<EmDashPreviewDB> {
89
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env
90
+ const ns = (env as Record<string, unknown>)[binding];
91
+ if (!ns) {
92
+ throw new Error(`Playground binding "${binding}" not found in environment`);
93
+ }
94
+ // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- DO namespace from untyped env
95
+ const namespace = ns as DurableObjectNamespace<EmDashPreviewDB>;
96
+ const doId = namespace.idFromName(token);
97
+ return namespace.get(doId);
98
+ }
99
+
100
+ /**
101
+ * Derive a created-at timestamp from the ULID session token.
102
+ */
103
+ function getSessionCreatedAt(token: string): string {
104
+ try {
105
+ const ENCODING = "0123456789ABCDEFGHJKMNPQRSTVWXYZ";
106
+ let time = 0;
107
+ const chars = token.toUpperCase().slice(0, 10);
108
+ for (const char of chars) {
109
+ time = time * 32 + ENCODING.indexOf(char);
110
+ }
111
+ return new Date(time).toISOString();
112
+ } catch {
113
+ return new Date().toISOString();
114
+ }
115
+ }
116
+
117
+ /**
118
+ * Initialize a playground DO: run migrations, apply seed, create admin user.
119
+ */
120
+ async function initializePlayground(
121
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
122
+ db: Kysely<any>,
123
+ token: string,
124
+ ): Promise<void> {
125
+ // Check if already initialized (persisted in the DO)
126
+ try {
127
+ const { rows } = await sql<{ value: string }>`
128
+ SELECT value FROM options WHERE name = ${"emdash:setup_complete"}
129
+ `.execute(db);
130
+
131
+ if (rows.length > 0) {
132
+ return;
133
+ }
134
+ } catch {
135
+ // Table doesn't exist yet -- first initialization
136
+ }
137
+
138
+ console.log(`[playground] Initializing session ${token}`);
139
+
140
+ // 1. Run all EmDash migrations.
141
+ // If the DO was previously initialized (persisted state) but somehow the
142
+ // setup_complete flag is missing, migrations may partially fail on tables
143
+ // that already exist. Treat migration errors as non-fatal if there are
144
+ // tables present (i.e. the DO was previously initialized).
145
+ const { runMigrations } = await import("emdash/db");
146
+ try {
147
+ const migrations = await runMigrations(db);
148
+ console.log(`[playground] Migrations applied: ${migrations.applied.length}`);
149
+ } catch (migrationError) {
150
+ // Check if this looks like a "tables already exist" error -- the DO
151
+ // was probably initialized in a previous Worker lifetime and the
152
+ // options check above failed for a transient reason.
153
+ const msg = migrationError instanceof Error ? migrationError.message : String(migrationError);
154
+ if (msg.includes("already exists")) {
155
+ console.log(`[playground] Migrations skipped (tables already exist)`);
156
+ // Mark setup complete if it wasn't (recover from partial init)
157
+ try {
158
+ await sql`
159
+ INSERT OR IGNORE INTO options (name, value)
160
+ VALUES (${"emdash:setup_complete"}, ${JSON.stringify(true)})
161
+ `.execute(db);
162
+ } catch {
163
+ // Best effort
164
+ }
165
+ return;
166
+ }
167
+ throw migrationError;
168
+ }
169
+
170
+ // 2. Load and apply seed with content (skip media downloads)
171
+ const { loadSeed } = await import("emdash/seed");
172
+ const { applySeed } = await import("emdash");
173
+ const seed = await loadSeed();
174
+ const seedResult = await applySeed(db, seed, {
175
+ includeContent: true,
176
+ onConflict: "skip",
177
+ skipMediaDownload: true,
178
+ });
179
+ console.log(
180
+ `[playground] Seed applied: ${seedResult.collections.created} collections, ${seedResult.content.created} content entries`,
181
+ );
182
+
183
+ // 3. Create anonymous admin user
184
+ const now = new Date().toISOString();
185
+ try {
186
+ await sql`
187
+ INSERT INTO users (id, email, name, role, email_verified, created_at, updated_at)
188
+ VALUES (${PLAYGROUND_USER_ID}, ${PLAYGROUND_USER_EMAIL}, ${PLAYGROUND_USER_NAME},
189
+ ${PLAYGROUND_USER_ROLE}, ${1}, ${now}, ${now})
190
+ `.execute(db);
191
+ } catch {
192
+ // User might already exist
193
+ }
194
+
195
+ // 4. Mark setup complete
196
+ try {
197
+ await sql`
198
+ INSERT INTO options (name, value)
199
+ VALUES (${"emdash:setup_complete"}, ${JSON.stringify(true)})
200
+ `.execute(db);
201
+ } catch {
202
+ // May already exist
203
+ }
204
+
205
+ // 5. Set site title
206
+ try {
207
+ await sql`
208
+ INSERT OR REPLACE INTO options (name, value)
209
+ VALUES (${"emdash:site_title"}, ${JSON.stringify("EmDash Playground")})
210
+ `.execute(db);
211
+ } catch {
212
+ // Non-critical
213
+ }
214
+
215
+ console.log(`[playground] Session ${token} initialized`);
216
+ }
217
+
218
+ /**
219
+ * Inject playground toolbar HTML into an HTML response.
220
+ */
221
+ async function injectPlaygroundToolbar(
222
+ response: Response,
223
+ config: { createdAt: string; ttl: number; editMode: boolean },
224
+ ): Promise<Response> {
225
+ const contentType = response.headers.get("content-type");
226
+ if (!contentType?.includes("text/html")) return response;
227
+
228
+ const html = await response.text();
229
+ if (!html.includes("</body>")) return new Response(html, response);
230
+
231
+ const toolbarHtml = renderPlaygroundToolbar(config);
232
+ const injected = html.replace("</body>", `${toolbarHtml}</body>`);
233
+ return new Response(injected, {
234
+ status: response.status,
235
+ headers: response.headers,
236
+ });
237
+ }
238
+
239
+ export const onRequest = defineMiddleware(async (context, next) => {
240
+ const { url, cookies } = context;
241
+ const ttl = DEFAULT_TTL;
242
+
243
+ // Lazy-load binding name from virtual config
244
+ const binding = getBindingName();
245
+
246
+ // --- Entry point: /playground ---
247
+ if (url.pathname === "/playground") {
248
+ let token = cookies.get(COOKIE_NAME)?.value;
249
+ if (!token) {
250
+ token = ulid();
251
+ cookies.set(COOKIE_NAME, token, {
252
+ httpOnly: true,
253
+ sameSite: "lax",
254
+ path: "/",
255
+ maxAge: ttl,
256
+ });
257
+ }
258
+
259
+ const stub = getStub(binding, token);
260
+ const dialect = new PreviewDODialect({ getStub: () => stub });
261
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
262
+ const db = new Kysely<any>({ dialect });
263
+
264
+ if (!initializedSessions.has(token)) {
265
+ await initializePlayground(db, token);
266
+ initializedSessions.add(token);
267
+ const fullStub = getFullStub(binding, token);
268
+ await fullStub.setTtlAlarm(ttl);
269
+ }
270
+
271
+ return context.redirect("/_emdash/admin");
272
+ }
273
+
274
+ // --- Reset endpoint ---
275
+ // Instead of dropping tables on the old DO (which is fragile and races
276
+ // with cached state), just clear the cookie and redirect to /playground.
277
+ // That creates a brand new DO with a fresh session -- clean slate.
278
+ // The old DO expires via its TTL alarm.
279
+ if (url.pathname === "/_playground/reset") {
280
+ cookies.delete(COOKIE_NAME, { path: "/" });
281
+ return context.redirect("/playground");
282
+ }
283
+
284
+ // --- Route gating ---
285
+ if (isBlockedInPlayground(url.pathname)) {
286
+ return Response.json(
287
+ { error: { code: "PLAYGROUND_MODE", message: "Not available in playground mode" } },
288
+ { status: 403 },
289
+ );
290
+ }
291
+
292
+ // --- Resolve session ---
293
+ const token = cookies.get(COOKIE_NAME)?.value;
294
+ if (!token) {
295
+ // No session -- redirect to /playground to create one
296
+ return context.redirect("/playground");
297
+ }
298
+
299
+ // --- Set up DO database and ALS ---
300
+ const stub = getStub(binding, token);
301
+ const dialect = new PreviewDODialect({ getStub: () => stub });
302
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
303
+ const db = new Kysely<any>({ dialect });
304
+
305
+ // Ensure initialized
306
+ if (!initializedSessions.has(token)) {
307
+ try {
308
+ await initializePlayground(db, token);
309
+ initializedSessions.add(token);
310
+ const fullStub = getFullStub(binding, token);
311
+ await fullStub.setTtlAlarm(ttl);
312
+ } catch (error) {
313
+ console.error("Playground initialization failed:", error);
314
+ return Response.json(
315
+ { error: { code: "PLAYGROUND_INIT_ERROR", message: "Failed to initialize playground" } },
316
+ { status: 500 },
317
+ );
318
+ }
319
+ }
320
+
321
+ // Stash the DO database and user on locals so downstream middleware
322
+ // (runtime init, request-context) can use them. We can't use ALS directly
323
+ // because this middleware is in @emdash-cms/cloudflare and resolves to a
324
+ // different AsyncLocalStorage instance than the emdash core package
325
+ // (workerd loads dist modules separately from Vite's source modules).
326
+ // The request-context middleware (same module context as the loader)
327
+ // detects locals.__playgroundDb and wraps the render in runWithContext().
328
+ // The __playgroundDb property is declared on App.Locals in emdash's locals.d.ts.
329
+ Object.assign(context.locals, { __playgroundDb: db, user: PLAYGROUND_USER });
330
+
331
+ const editMode = cookies.get("emdash-edit-mode")?.value === "true";
332
+
333
+ const response = await next();
334
+
335
+ return injectPlaygroundToolbar(response, {
336
+ createdAt: getSessionCreatedAt(token),
337
+ ttl,
338
+ editMode,
339
+ });
340
+ });