@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
package/dist/db/d1.mjs ADDED
@@ -0,0 +1,74 @@
1
+ import { t as D1Introspector } from "../d1-introspector-bZf0_ylK.mjs";
2
+ import { env } from "cloudflare:workers";
3
+ import { D1Dialect } from "kysely-d1";
4
+
5
+ //#region src/db/d1.ts
6
+ /**
7
+ * Cloudflare D1 runtime adapter - RUNTIME ENTRY
8
+ *
9
+ * Creates a Kysely dialect for D1.
10
+ * Loaded at runtime via virtual module when database queries are needed.
11
+ *
12
+ * This module imports directly from cloudflare:workers to access the D1 binding.
13
+ * Do NOT import this at config time - use { d1 } from "@emdash-cms/cloudflare" instead.
14
+ */
15
+ /**
16
+ * Custom D1 Dialect that uses our D1-compatible introspector
17
+ *
18
+ * The default kysely-d1 dialect uses SqliteIntrospector which does a
19
+ * cross-join with pragma_table_info() that D1 doesn't allow.
20
+ */
21
+ var EmDashD1Dialect = class extends D1Dialect {
22
+ createIntrospector(db) {
23
+ return new D1Introspector(db);
24
+ }
25
+ };
26
+ /**
27
+ * Create a D1 dialect from config
28
+ *
29
+ * @param config - D1 configuration with binding name
30
+ */
31
+ function createDialect(config) {
32
+ const db = env[config.binding];
33
+ if (!db) throw new Error(`D1 binding "${config.binding}" not found in environment. Check your wrangler.toml configuration:\n\n[[d1_databases]]\nbinding = "${config.binding}"\ndatabase_name = "your-database-name"\ndatabase_id = "your-database-id"`);
34
+ return new EmDashD1Dialect({ database: db });
35
+ }
36
+ /**
37
+ * Whether D1 sessions are enabled in the config.
38
+ */
39
+ function isSessionEnabled(config) {
40
+ return !!config.session && config.session !== "disabled";
41
+ }
42
+ /**
43
+ * Get the raw D1 binding for creating sessions.
44
+ * Returns null if sessions are disabled.
45
+ */
46
+ function getD1Binding(config) {
47
+ if (!isSessionEnabled(config)) return null;
48
+ return env[config.binding] ?? null;
49
+ }
50
+ /**
51
+ * Get the default session constraint for the config's session mode.
52
+ */
53
+ function getDefaultConstraint(config) {
54
+ if (config.session === "primary-first") return "first-primary";
55
+ return "first-unconstrained";
56
+ }
57
+ /**
58
+ * Get the cookie name used for storing D1 session bookmarks.
59
+ */
60
+ function getBookmarkCookieName(config) {
61
+ return config.bookmarkCookie ?? "__ec_d1_bookmark";
62
+ }
63
+ /**
64
+ * Create a Kysely dialect from a D1 session object.
65
+ *
66
+ * D1DatabaseSession has the same `prepare()` / `batch()` interface
67
+ * as D1Database, so we pass it directly to D1Dialect.
68
+ */
69
+ function createSessionDialect(session) {
70
+ return new EmDashD1Dialect({ database: session });
71
+ }
72
+
73
+ //#endregion
74
+ export { createDialect, createSessionDialect, getBookmarkCookieName, getD1Binding, getDefaultConstraint, isSessionEnabled };
@@ -0,0 +1,96 @@
1
+ import { t as PreviewDOConfig } from "../do-types-CY0G0oyh.mjs";
2
+ import { t as EmDashPreviewDB } from "../do-class-x5Xh_G62.mjs";
3
+ import { Dialect } from "kysely";
4
+ import { MiddlewareHandler } from "astro";
5
+
6
+ //#region src/db/do-preview.d.ts
7
+ /** Configuration for the preview middleware */
8
+ interface PreviewMiddlewareConfig {
9
+ /** Durable Object binding name (from wrangler.jsonc) */
10
+ binding: string;
11
+ /** HMAC secret for validating signed preview URLs */
12
+ secret: string;
13
+ /** TTL for preview data in seconds (default: 3600 = 1 hour) */
14
+ ttl?: number;
15
+ /** Cookie name for session token (default: "emdash_preview") */
16
+ cookieName?: string;
17
+ }
18
+ /**
19
+ * Create an Astro-compatible preview middleware.
20
+ *
21
+ * Returns a middleware function that can be used in `defineMiddleware()`
22
+ * or composed via `sequence()`.
23
+ */
24
+ declare function createPreviewMiddleware(config: PreviewMiddlewareConfig): MiddlewareHandler;
25
+ //#endregion
26
+ //#region src/db/do-preview-routes.d.ts
27
+ /**
28
+ * Preview mode route gating.
29
+ *
30
+ * Pure function — no Worker or Cloudflare dependencies.
31
+ * Extracted so it can be tested without mocking cloudflare:workers.
32
+ */
33
+ /**
34
+ * Check whether a request should be blocked in preview mode.
35
+ *
36
+ * Preview is read-only with no authenticated user. All /_emdash/
37
+ * routes are blocked by default (admin UI, auth, setup, write APIs).
38
+ * Only specific read-only API prefixes are allowlisted.
39
+ *
40
+ * Non-emdash routes (site pages, assets) are always allowed.
41
+ */
42
+ declare function isBlockedInPreview(pathname: string): boolean;
43
+ //#endregion
44
+ //#region src/db/do-preview-sign.d.ts
45
+ /**
46
+ * Preview URL signing utilities.
47
+ *
48
+ * Pure functions using Web Crypto — no Worker or Cloudflare dependencies.
49
+ * Used by the source site to generate signed preview URLs and by the
50
+ * preview service to verify them.
51
+ */
52
+ /**
53
+ * Generate a signed preview URL.
54
+ *
55
+ * The source site calls this to create a link that opens the preview service.
56
+ * The preview service validates the signature and populates the DO from a
57
+ * snapshot of the source site.
58
+ *
59
+ * @param previewBase - Base URL of the preview service (e.g. "https://theme-x.preview.emdashcms.com")
60
+ * @param source - URL of the source site providing the snapshot (e.g. "https://mysite.com")
61
+ * @param secret - Shared HMAC secret (same value configured on both sides)
62
+ * @param ttl - Link validity in seconds (default: 3600 = 1 hour)
63
+ * @returns Fully signed preview URL
64
+ *
65
+ * @example
66
+ * ```ts
67
+ * const url = await signPreviewUrl(
68
+ * "https://theme-x.preview.emdashcms.com",
69
+ * "https://mysite.com",
70
+ * import.meta.env.PREVIEW_SECRET,
71
+ * );
72
+ * // => "https://theme-x.preview.emdashcms.com/?source=https%3A%2F%2Fmysite.com&exp=1709164800&sig=abc123..."
73
+ * ```
74
+ */
75
+ declare function signPreviewUrl(previewBase: string, source: string, secret: string, ttl?: number): Promise<string>;
76
+ /**
77
+ * Verify an HMAC-SHA256 signature on a preview URL.
78
+ *
79
+ * Uses crypto.subtle.verify for constant-time comparison.
80
+ *
81
+ * @returns true if the signature is valid
82
+ */
83
+ declare function verifyPreviewSignature(source: string, exp: number, sig: string, secret: string): Promise<boolean>;
84
+ //#endregion
85
+ //#region src/db/do.d.ts
86
+ /**
87
+ * Create a preview DO dialect from config.
88
+ *
89
+ * The caller is responsible for resolving the DO name (session token).
90
+ * This is passed as `config.name` by the preview middleware.
91
+ */
92
+ declare function createDialect(config: PreviewDOConfig & {
93
+ name: string;
94
+ }): Dialect;
95
+ //#endregion
96
+ export { EmDashPreviewDB, type PreviewMiddlewareConfig, createDialect, createPreviewMiddleware, isBlockedInPreview, signPreviewUrl, verifyPreviewSignature };
package/dist/db/do.mjs ADDED
@@ -0,0 +1,489 @@
1
+ import "../d1-introspector-bZf0_ylK.mjs";
2
+ import { t as PreviewDODialect } from "../do-dialect-BhFcRSFQ.mjs";
3
+ import { t as EmDashPreviewDB } from "../do-class-DY2Ba2RJ.mjs";
4
+ import { env } from "cloudflare:workers";
5
+ import { Kysely } from "kysely";
6
+ import { runWithContext } from "emdash/request-context";
7
+ import { ulid } from "ulidx";
8
+
9
+ //#region src/db/do-preview-routes.ts
10
+ /**
11
+ * Preview mode route gating.
12
+ *
13
+ * Pure function — no Worker or Cloudflare dependencies.
14
+ * Extracted so it can be tested without mocking cloudflare:workers.
15
+ */
16
+ /**
17
+ * API route prefixes allowed in preview mode (read-only).
18
+ * Everything else under /_emdash/ is blocked.
19
+ */
20
+ const ALLOWED_API_PREFIXES = [
21
+ "/_emdash/api/content/",
22
+ "/_emdash/api/schema",
23
+ "/_emdash/api/manifest",
24
+ "/_emdash/api/dashboard",
25
+ "/_emdash/api/search",
26
+ "/_emdash/api/media",
27
+ "/_emdash/api/taxonomies",
28
+ "/_emdash/api/menus",
29
+ "/_emdash/api/snapshot"
30
+ ];
31
+ /**
32
+ * Check whether a request should be blocked in preview mode.
33
+ *
34
+ * Preview is read-only with no authenticated user. All /_emdash/
35
+ * routes are blocked by default (admin UI, auth, setup, write APIs).
36
+ * Only specific read-only API prefixes are allowlisted.
37
+ *
38
+ * Non-emdash routes (site pages, assets) are always allowed.
39
+ */
40
+ function isBlockedInPreview(pathname) {
41
+ if (!pathname.startsWith("/_emdash/")) return false;
42
+ for (const prefix of ALLOWED_API_PREFIXES) if (pathname === prefix || pathname.startsWith(prefix)) return false;
43
+ return true;
44
+ }
45
+
46
+ //#endregion
47
+ //#region src/db/do-preview-sign.ts
48
+ /**
49
+ * Preview URL signing utilities.
50
+ *
51
+ * Pure functions using Web Crypto — no Worker or Cloudflare dependencies.
52
+ * Used by the source site to generate signed preview URLs and by the
53
+ * preview service to verify them.
54
+ */
55
+ /** Matches a lowercase hex string */
56
+ const HEX_PATTERN = /^[0-9a-f]+$/;
57
+ /**
58
+ * Compute HMAC-SHA256 over a message and return the hex-encoded signature.
59
+ */
60
+ async function hmacSign(message, secret) {
61
+ const encoder = new TextEncoder();
62
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
63
+ name: "HMAC",
64
+ hash: "SHA-256"
65
+ }, false, ["sign"]);
66
+ const buffer = await crypto.subtle.sign("HMAC", key, encoder.encode(message));
67
+ return Array.from(new Uint8Array(buffer), (b) => b.toString(16).padStart(2, "0")).join("");
68
+ }
69
+ /**
70
+ * Generate a signed preview URL.
71
+ *
72
+ * The source site calls this to create a link that opens the preview service.
73
+ * The preview service validates the signature and populates the DO from a
74
+ * snapshot of the source site.
75
+ *
76
+ * @param previewBase - Base URL of the preview service (e.g. "https://theme-x.preview.emdashcms.com")
77
+ * @param source - URL of the source site providing the snapshot (e.g. "https://mysite.com")
78
+ * @param secret - Shared HMAC secret (same value configured on both sides)
79
+ * @param ttl - Link validity in seconds (default: 3600 = 1 hour)
80
+ * @returns Fully signed preview URL
81
+ *
82
+ * @example
83
+ * ```ts
84
+ * const url = await signPreviewUrl(
85
+ * "https://theme-x.preview.emdashcms.com",
86
+ * "https://mysite.com",
87
+ * import.meta.env.PREVIEW_SECRET,
88
+ * );
89
+ * // => "https://theme-x.preview.emdashcms.com/?source=https%3A%2F%2Fmysite.com&exp=1709164800&sig=abc123..."
90
+ * ```
91
+ */
92
+ async function signPreviewUrl(previewBase, source, secret, ttl = 3600) {
93
+ const exp = Math.floor(Date.now() / 1e3) + ttl;
94
+ const sig = await hmacSign(`${source}:${exp}`, secret);
95
+ const url = new URL(previewBase);
96
+ url.searchParams.set("source", source);
97
+ url.searchParams.set("exp", String(exp));
98
+ url.searchParams.set("sig", sig);
99
+ return url.toString();
100
+ }
101
+ /**
102
+ * Verify an HMAC-SHA256 signature on a preview URL.
103
+ *
104
+ * Uses crypto.subtle.verify for constant-time comparison.
105
+ *
106
+ * @returns true if the signature is valid
107
+ */
108
+ async function verifyPreviewSignature(source, exp, sig, secret) {
109
+ if (sig.length !== 64 || !HEX_PATTERN.test(sig)) return false;
110
+ const sigBytes = new Uint8Array(32);
111
+ for (let i = 0; i < 64; i += 2) sigBytes[i / 2] = parseInt(sig.substring(i, i + 2), 16);
112
+ const encoder = new TextEncoder();
113
+ const key = await crypto.subtle.importKey("raw", encoder.encode(secret), {
114
+ name: "HMAC",
115
+ hash: "SHA-256"
116
+ }, false, ["verify"]);
117
+ return crypto.subtle.verify("HMAC", key, sigBytes, encoder.encode(`${source}:${exp}`));
118
+ }
119
+
120
+ //#endregion
121
+ //#region src/db/preview-toolbar.ts
122
+ const RE_AMP = /&/g;
123
+ const RE_QUOT = /"/g;
124
+ const RE_LT = /</g;
125
+ const RE_GT = />/g;
126
+ function renderPreviewToolbar(config) {
127
+ const { generatedAt, source, error } = config;
128
+ return `
129
+ <!-- EmDash Preview Toolbar -->
130
+ <div id="emdash-preview-toolbar"${generatedAt ? ` data-generated-at="${escapeAttr(generatedAt)}"` : ""}${source ? ` data-source="${escapeAttr(source)}"` : ""}${error ? ` data-error="${escapeAttr(error)}"` : ""}>
131
+ <div class="ec-ptb-inner">
132
+ <span class="ec-ptb-badge">Preview</span>
133
+
134
+ <div class="ec-ptb-divider"></div>
135
+
136
+ <span class="ec-ptb-status" id="ec-ptb-status"></span>
137
+
138
+ <button class="ec-ptb-btn" id="ec-ptb-reload" title="Reload snapshot">
139
+ <svg class="ec-ptb-icon" id="ec-ptb-reload-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>
140
+ </button>
141
+
142
+ <button class="ec-ptb-btn ec-ptb-close" id="ec-ptb-dismiss" title="Dismiss toolbar">
143
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
144
+ </button>
145
+ </div>
146
+ </div>
147
+
148
+ <style>
149
+ #emdash-preview-toolbar {
150
+ position: fixed;
151
+ bottom: 16px;
152
+ left: 50%;
153
+ transform: translateX(-50%);
154
+ z-index: 999999;
155
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
156
+ font-size: 13px;
157
+ line-height: 1;
158
+ -webkit-font-smoothing: antialiased;
159
+ }
160
+
161
+ #emdash-preview-toolbar.ec-ptb-hidden {
162
+ display: none;
163
+ }
164
+
165
+ .ec-ptb-inner {
166
+ display: flex;
167
+ align-items: center;
168
+ gap: 10px;
169
+ padding: 8px 12px 8px 16px;
170
+ background: #1a1a1a;
171
+ color: #e0e0e0;
172
+ border-radius: 999px;
173
+ box-shadow: 0 4px 24px rgba(0,0,0,0.3), 0 0 0 1px rgba(255,255,255,0.08);
174
+ white-space: nowrap;
175
+ user-select: none;
176
+ }
177
+
178
+ .ec-ptb-badge {
179
+ display: inline-flex;
180
+ align-items: center;
181
+ padding: 3px 8px;
182
+ border-radius: 999px;
183
+ font-size: 11px;
184
+ font-weight: 600;
185
+ letter-spacing: 0.02em;
186
+ text-transform: uppercase;
187
+ background: rgba(139,92,246,0.2);
188
+ color: #a78bfa;
189
+ }
190
+
191
+ .ec-ptb-divider {
192
+ width: 1px;
193
+ height: 16px;
194
+ background: rgba(255,255,255,0.15);
195
+ }
196
+
197
+ .ec-ptb-status {
198
+ display: inline-flex;
199
+ align-items: center;
200
+ gap: 6px;
201
+ font-size: 12px;
202
+ color: #999;
203
+ }
204
+
205
+ .ec-ptb-status--error {
206
+ color: #f87171;
207
+ }
208
+
209
+ .ec-ptb-btn {
210
+ display: inline-flex;
211
+ align-items: center;
212
+ justify-content: center;
213
+ background: none;
214
+ border: none;
215
+ color: #888;
216
+ cursor: pointer;
217
+ padding: 4px;
218
+ border-radius: 4px;
219
+ transition: color 0.15s, background 0.15s;
220
+ font-family: inherit;
221
+ }
222
+
223
+ .ec-ptb-btn:hover {
224
+ color: #fff;
225
+ background: rgba(255,255,255,0.08);
226
+ }
227
+
228
+ .ec-ptb-icon {
229
+ transition: transform 0.3s;
230
+ }
231
+
232
+ .ec-ptb-btn:disabled {
233
+ opacity: 0.4;
234
+ cursor: not-allowed;
235
+ }
236
+
237
+ .ec-ptb-btn:disabled:hover {
238
+ color: #888;
239
+ background: none;
240
+ }
241
+
242
+ @keyframes ec-ptb-spin {
243
+ to { transform: rotate(360deg); }
244
+ }
245
+
246
+ .ec-ptb-spinning .ec-ptb-icon {
247
+ animation: ec-ptb-spin 0.8s linear infinite;
248
+ }
249
+ </style>
250
+
251
+ <script>
252
+ (function() {
253
+ var toolbar = document.getElementById("emdash-preview-toolbar");
254
+ var statusEl = document.getElementById("ec-ptb-status");
255
+ var reloadBtn = document.getElementById("ec-ptb-reload");
256
+ var dismissBtn = document.getElementById("ec-ptb-dismiss");
257
+ if (!toolbar || !statusEl || !reloadBtn || !dismissBtn) return;
258
+
259
+ var generatedAt = toolbar.getAttribute("data-generated-at");
260
+ var source = toolbar.getAttribute("data-source");
261
+ var error = toolbar.getAttribute("data-error");
262
+
263
+ function formatAge(isoString) {
264
+ if (!isoString) return null;
265
+ var then = new Date(isoString).getTime();
266
+ var now = Date.now();
267
+ var seconds = Math.floor((now - then) / 1000);
268
+ if (seconds < 60) return "just now";
269
+ var minutes = Math.floor(seconds / 60);
270
+ if (minutes < 60) return minutes + "m ago";
271
+ var hours = Math.floor(minutes / 60);
272
+ if (hours < 24) return hours + "h ago";
273
+ return Math.floor(hours / 24) + "d ago";
274
+ }
275
+
276
+ function updateStatus() {
277
+ if (error) {
278
+ statusEl.className = "ec-ptb-status ec-ptb-status--error";
279
+ statusEl.textContent = error;
280
+ return;
281
+ }
282
+ var age = formatAge(generatedAt);
283
+ statusEl.className = "ec-ptb-status";
284
+ statusEl.textContent = age ? "Snapshot " + age : "Preview mode";
285
+ }
286
+
287
+ updateStatus();
288
+
289
+ // Update age display every 30s
290
+ var ageInterval = setInterval(updateStatus, 30000);
291
+
292
+ // Reload: hit the server endpoint which clears the httpOnly session cookie
293
+ // and redirects back with the original signed params for a fresh snapshot.
294
+ reloadBtn.addEventListener("click", function() {
295
+ reloadBtn.disabled = true;
296
+ reloadBtn.classList.add("ec-ptb-spinning");
297
+ statusEl.className = "ec-ptb-status";
298
+ statusEl.textContent = "Reloading\u2026";
299
+ location.href = "/_preview/reload";
300
+ });
301
+
302
+ // Dismiss
303
+ dismissBtn.addEventListener("click", function() {
304
+ toolbar.classList.add("ec-ptb-hidden");
305
+ clearInterval(ageInterval);
306
+ });
307
+ })();
308
+ <\/script>
309
+ `;
310
+ }
311
+ function escapeAttr(str) {
312
+ return str.replace(RE_AMP, "&amp;").replace(RE_QUOT, "&quot;").replace(RE_LT, "&lt;").replace(RE_GT, "&gt;");
313
+ }
314
+
315
+ //#endregion
316
+ //#region src/db/do-preview.ts
317
+ /**
318
+ * Simple loading interstitial HTML.
319
+ * Auto-reloads after a short delay to check if the snapshot is ready.
320
+ */
321
+ function loadingPage() {
322
+ return `<!DOCTYPE html>
323
+ <html lang="en">
324
+ <head>
325
+ <meta charset="utf-8">
326
+ <meta name="viewport" content="width=device-width, initial-scale=1">
327
+ <meta http-equiv="refresh" content="2">
328
+ <title>Loading preview...</title>
329
+ <style>
330
+ body { font-family: system-ui, sans-serif; display: flex; align-items: center; justify-content: center; min-height: 100vh; margin: 0; background: #fafafa; color: #333; }
331
+ .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; }
332
+ @keyframes spin { to { transform: rotate(360deg); } }
333
+ </style>
334
+ </head>
335
+ <body>
336
+ <div class="spinner"></div>
337
+ <p>Loading preview&hellip;</p>
338
+ </body>
339
+ </html>`;
340
+ }
341
+ /**
342
+ * Create an Astro-compatible preview middleware.
343
+ *
344
+ * Returns a middleware function that can be used in `defineMiddleware()`
345
+ * or composed via `sequence()`.
346
+ */
347
+ function createPreviewMiddleware(config) {
348
+ const { binding, secret, ttl = 3600, cookieName = "emdash_preview" } = config;
349
+ return async function previewMiddleware(context, next) {
350
+ const { url, cookies } = context;
351
+ if (url.pathname === "/_preview/reload") {
352
+ cookies.delete(cookieName, { path: "/" });
353
+ let redirectTo = "/";
354
+ const paramsCookie = cookies.get(`${cookieName}_params`)?.value;
355
+ if (paramsCookie) {
356
+ const parts = decodeURIComponent(paramsCookie).split("\n");
357
+ if (parts.length === 3) {
358
+ const reloadUrl = new URL("/", url.origin);
359
+ reloadUrl.searchParams.set("source", parts[0]);
360
+ reloadUrl.searchParams.set("exp", parts[1]);
361
+ reloadUrl.searchParams.set("sig", parts[2]);
362
+ redirectTo = reloadUrl.pathname + reloadUrl.search;
363
+ }
364
+ }
365
+ return context.redirect(redirectTo);
366
+ }
367
+ if (isBlockedInPreview(url.pathname)) return Response.json({ error: {
368
+ code: "PREVIEW_MODE",
369
+ message: "Not available in preview mode"
370
+ } }, { status: 403 });
371
+ let sessionToken = cookies.get(cookieName)?.value;
372
+ let sourceUrl = null;
373
+ let snapshotSignature = null;
374
+ if (!sessionToken) {
375
+ const source = url.searchParams.get("source");
376
+ const exp = url.searchParams.get("exp");
377
+ const sig = url.searchParams.get("sig");
378
+ if (!source || !exp || !sig) return new Response("Missing preview parameters", { status: 400 });
379
+ const expNum = parseInt(exp, 10);
380
+ if (isNaN(expNum) || expNum < Date.now() / 1e3) return new Response("Preview link expired", { status: 403 });
381
+ if (!await verifyPreviewSignature(source, expNum, sig, secret)) return new Response("Invalid preview signature", { status: 403 });
382
+ sessionToken = ulid();
383
+ sourceUrl = source;
384
+ snapshotSignature = `${source}:${exp}:${sig}`;
385
+ cookies.set(cookieName, sessionToken, {
386
+ httpOnly: true,
387
+ sameSite: "lax",
388
+ path: "/",
389
+ maxAge: ttl
390
+ });
391
+ cookies.set(`${cookieName}_params`, `${source}\n${exp}\n${sig}`, {
392
+ sameSite: "lax",
393
+ path: "/",
394
+ maxAge: ttl
395
+ });
396
+ }
397
+ const ns = env[binding];
398
+ if (!ns) {
399
+ console.error(`Preview binding "${binding}" not found in environment`);
400
+ return new Response("Preview service misconfigured", { status: 500 });
401
+ }
402
+ const namespace = ns;
403
+ const doId = namespace.idFromName(sessionToken);
404
+ const stub = namespace.get(doId);
405
+ let snapshotGeneratedAt;
406
+ let snapshotError;
407
+ if (!sourceUrl) try {
408
+ snapshotGeneratedAt = (await stub.getSnapshotMeta())?.generatedAt;
409
+ } catch {}
410
+ if (sourceUrl && snapshotSignature) try {
411
+ snapshotGeneratedAt = (await stub.populateFromSnapshot(sourceUrl, snapshotSignature, { ttl })).generatedAt;
412
+ const cleanUrl = new URL(url);
413
+ cleanUrl.searchParams.delete("source");
414
+ cleanUrl.searchParams.delete("exp");
415
+ cleanUrl.searchParams.delete("sig");
416
+ return context.redirect(cleanUrl.pathname + cleanUrl.search);
417
+ } catch (error) {
418
+ const message = error instanceof Error ? error.message : String(error);
419
+ console.error("Failed to populate preview snapshot:", message);
420
+ snapshotError = message;
421
+ if (!cookies.get(cookieName)?.value) return new Response(loadingPage(), {
422
+ status: 503,
423
+ headers: {
424
+ "Content-Type": "text/html",
425
+ "Retry-After": "2"
426
+ }
427
+ });
428
+ }
429
+ const getStub = () => {
430
+ return stub;
431
+ };
432
+ return runWithContext({
433
+ editMode: false,
434
+ db: new Kysely({ dialect: new PreviewDODialect({ getStub }) })
435
+ }, async () => {
436
+ return injectPreviewToolbar(await next(), {
437
+ generatedAt: snapshotGeneratedAt,
438
+ source: sourceUrl ?? void 0,
439
+ error: snapshotError
440
+ });
441
+ });
442
+ };
443
+ }
444
+ /**
445
+ * Inject preview toolbar HTML into an HTML response.
446
+ * Returns the original response unchanged for non-HTML responses.
447
+ */
448
+ async function injectPreviewToolbar(response, config) {
449
+ if (!response.headers.get("content-type")?.includes("text/html")) return response;
450
+ const html = await response.text();
451
+ if (!html.includes("</body>")) return new Response(html, response);
452
+ const toolbarHtml = renderPreviewToolbar(config);
453
+ const injected = html.replace("</body>", `${toolbarHtml}</body>`);
454
+ return new Response(injected, {
455
+ status: response.status,
456
+ headers: response.headers
457
+ });
458
+ }
459
+
460
+ //#endregion
461
+ //#region src/db/do.ts
462
+ /**
463
+ * Durable Object preview database — RUNTIME ENTRY
464
+ *
465
+ * Creates a Kysely dialect backed by a preview Durable Object.
466
+ * Loaded at runtime via virtual module when preview database queries are needed.
467
+ *
468
+ * This module imports directly from cloudflare:workers to access the DO binding.
469
+ * Do NOT import this at config time.
470
+ */
471
+ /**
472
+ * Create a preview DO dialect from config.
473
+ *
474
+ * The caller is responsible for resolving the DO name (session token).
475
+ * This is passed as `config.name` by the preview middleware.
476
+ */
477
+ function createDialect(config) {
478
+ const ns = env[config.binding];
479
+ if (!ns) throw new Error(`Durable Object binding "${config.binding}" not found in environment. Check your wrangler.jsonc configuration:\n\n[durable_objects]\nbindings = [\n { name = "${config.binding}", class_name = "EmDashPreviewDB" }\n]\n\n[[migrations]]\ntag = "v1"\nnew_sqlite_classes = ["EmDashPreviewDB"]`);
480
+ const namespace = ns;
481
+ const id = namespace.idFromName(config.name);
482
+ const getStub = () => {
483
+ return namespace.get(id);
484
+ };
485
+ return new PreviewDODialect({ getStub });
486
+ }
487
+
488
+ //#endregion
489
+ export { EmDashPreviewDB, createDialect, createPreviewMiddleware, isBlockedInPreview, signPreviewUrl, verifyPreviewSignature };
@@ -0,0 +1,20 @@
1
+ import * as astro from "astro";
2
+
3
+ //#region src/db/playground-middleware.d.ts
4
+ /**
5
+ * Playground middleware — injected by the EmDash integration as order: "pre".
6
+ *
7
+ * Runs BEFORE the EmDash runtime init middleware. Creates a per-session
8
+ * Durable Object database, runs migrations, applies the seed, creates an
9
+ * anonymous admin user, and sets the DB in ALS via runWithContext().
10
+ *
11
+ * By the time the runtime middleware runs, the ALS-scoped DB is ready.
12
+ * The runtime's `db` getter checks ALS first, so all init queries
13
+ * (migrations, FTS, cron, manifest) operate on the real DO database.
14
+ *
15
+ * This module is registered via `addMiddleware({ entrypoint: "..." })` in
16
+ * the integration, NOT in the user's src/middleware.ts.
17
+ */
18
+ declare const onRequest: astro.MiddlewareHandler;
19
+ //#endregion
20
+ export { onRequest };