/** * Preview middleware for Durable Object-backed preview databases. * * This middleware intercepts requests to a preview service, validates * signed preview URLs, creates/resolves DO sessions, populates snapshots, * and overrides the request-context DB so all queries route to the * isolated DO database. * * Designed to be registered as Astro middleware in a preview Worker. * * @example * ```ts * // src/middleware.ts (in the preview Worker) * import { createPreviewMiddleware } from "@emdash-cms/cloudflare/db/do"; * * export const onRequest = createPreviewMiddleware({ * binding: "PREVIEW_DB", * secret: import.meta.env.PREVIEW_SECRET, * }); * ``` */ import type { MiddlewareHandler } from "astro"; import { env } from "cloudflare:workers"; import { runWithContext } from "emdash/request-context"; import { Kysely } from "kysely"; import { ulid } from "ulidx"; import type { EmDashPreviewDB } from "./do-class.js"; import { PreviewDODialect } from "./do-dialect.js"; import type { PreviewDBStub } from "./do-dialect.js"; import { isBlockedInPreview } from "./do-preview-routes.js"; import { verifyPreviewSignature } from "./do-preview-sign.js"; import { renderPreviewToolbar } from "./preview-toolbar.js"; /** Configuration for the preview middleware */ export interface PreviewMiddlewareConfig { /** Durable Object binding name (from wrangler.jsonc) */ binding: string; /** HMAC secret for validating signed preview URLs */ secret: string; /** TTL for preview data in seconds (default: 3600 = 1 hour) */ ttl?: number; /** Cookie name for session token (default: "emdash_preview") */ cookieName?: string; } /** * Simple loading interstitial HTML. * Auto-reloads after a short delay to check if the snapshot is ready. */ function loadingPage(): string { return `
Loading preview…
`; } /** * Create an Astro-compatible preview middleware. * * Returns a middleware function that can be used in `defineMiddleware()` * or composed via `sequence()`. */ export function createPreviewMiddleware(config: PreviewMiddlewareConfig): MiddlewareHandler { const { binding, secret, ttl = 3600, cookieName = "emdash_preview" } = config; return async function previewMiddleware(context, next) { const { url, cookies } = context; // --- 0a. Reload endpoint --- // The toolbar POSTs here to clear the httpOnly session cookie and // redirect back with the original signed params for a fresh snapshot. if (url.pathname === "/_preview/reload") { cookies.delete(cookieName, { path: "/" }); let redirectTo = "/"; const paramsCookie = cookies.get(`${cookieName}_params`)?.value; if (paramsCookie) { const parts = decodeURIComponent(paramsCookie).split("\n"); if (parts.length === 3) { const reloadUrl = new URL("/", url.origin); reloadUrl.searchParams.set("source", parts[0]!); reloadUrl.searchParams.set("exp", parts[1]!); reloadUrl.searchParams.set("sig", parts[2]!); redirectTo = reloadUrl.pathname + reloadUrl.search; } } return context.redirect(redirectTo); } // --- 0b. Route gating --- // Block admin UI, auth, and setup routes. These depend on state // (users, sessions, credentials) that doesn't exist in preview snapshots. if (isBlockedInPreview(url.pathname)) { return Response.json( { error: { code: "PREVIEW_MODE", message: "Not available in preview mode" } }, { status: 403 }, ); } // --- 1. Resolve session token --- let sessionToken: string | undefined = cookies.get(cookieName)?.value; let sourceUrl: string | null = null; let snapshotSignature: string | null = null; if (!sessionToken) { // No cookie — must have a signed URL const source = url.searchParams.get("source"); const exp = url.searchParams.get("exp"); const sig = url.searchParams.get("sig"); if (!source || !exp || !sig) { return new Response("Missing preview parameters", { status: 400 }); } const expNum = parseInt(exp, 10); if (isNaN(expNum) || expNum < Date.now() / 1000) { return new Response("Preview link expired", { status: 403 }); } const valid = await verifyPreviewSignature(source, expNum, sig, secret); if (!valid) { return new Response("Invalid preview signature", { status: 403 }); } // Generate session sessionToken = ulid(); sourceUrl = source; // Build the signature header value for snapshot fetch: "source:exp:sig" snapshotSignature = `${source}:${exp}:${sig}`; cookies.set(cookieName, sessionToken, { httpOnly: true, sameSite: "lax", path: "/", maxAge: ttl, }); // Store the signed params so the toolbar can trigger a reload. // Not httpOnly — the toolbar script needs to read them. cookies.set(`${cookieName}_params`, `${source}\n${exp}\n${sig}`, { sameSite: "lax", path: "/", maxAge: ttl, }); } // --- 2. Get DO stub --- // eslint-disable-next-line typescript-eslint(no-unsafe-type-assertion) -- Worker binding from untyped env const ns = (env as Record