From 63cb4c7adc7a1800479ab51cfd8a5d79dadc69cb Mon Sep 17 00:00:00 2001 From: Chris Huber Date: Sun, 26 Jul 2026 19:38:12 -0400 Subject: [PATCH] Isolate Cloudflare public reads from PHP-WASM --- package.json | 3 +- packages/runtime-cloudflare/README.md | 4 +- .../src/public-reader-worker.ts | 3 + .../runtime-cloudflare/src/public-reader.ts | 167 ++++++++++++++++++ .../src/published-reader.ts | 21 ++- .../src/wordpress-static-reader.ts | 59 +++++++ packages/runtime-cloudflare/src/worker.ts | 5 +- .../wrangler.public-reader.jsonc | 10 ++ tests/cloudflare-public-reader.test.ts | 91 ++++++++++ tests/cloudflare-runtime.test.ts | 6 +- 10 files changed, 358 insertions(+), 11 deletions(-) create mode 100644 packages/runtime-cloudflare/src/public-reader-worker.ts create mode 100644 packages/runtime-cloudflare/src/public-reader.ts create mode 100644 packages/runtime-cloudflare/src/wordpress-static-reader.ts create mode 100644 packages/runtime-cloudflare/wrangler.public-reader.jsonc create mode 100644 tests/cloudflare-public-reader.test.ts diff --git a/package.json b/package.json index 071b893e..07bb3d6f 100644 --- a/package.json +++ b/package.json @@ -89,6 +89,7 @@ "build:release": "node ./node_modules/typescript/bin/tsc -b --force packages/runtime-core packages/runtime-playground packages/cli && node scripts/ensure-cli-bin-executable.mjs", "cloudflare:dry-run": "npm exec -- wrangler deploy --dry-run --config packages/runtime-cloudflare/wrangler.jsonc", "cloudflare:dry-run:d1": "npm exec -- wrangler deploy --dry-run --config packages/runtime-cloudflare/wrangler.d1.jsonc", + "cloudflare:dry-run:public-reader": "npm exec -- wrangler deploy --dry-run --config packages/runtime-cloudflare/wrangler.public-reader.jsonc", "cloudflare:local-gate": "node scripts/cloudflare-local-gate.mjs", "cloudflare:local-gate:d1": "node scripts/cloudflare-local-gate.mjs --coordinator=d1", "cloudflare:local-gate:provisioning": "node scripts/cloudflare-local-gate.mjs --coordinator=d1 --public-provisioning", @@ -110,7 +111,7 @@ "test:redaction": "tsx tests/redaction.test.ts", "test:browser-preview-routing": "tsx --test tests/browser-preview-routing.test.ts", "test:browser-routed-command-security": "tsx --test tests/browser-routed-command-security.test.ts", - "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-allocation-lifecycle.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", + "test:cloudflare-runtime": "node --test tests/cloudflare-d1-provisioner.test.mjs && tsx tests/cloudflare-site-context.test.ts && tsx tests/cloudflare-coordinator-site-partitioning.test.ts && tsx tests/cloudflare-d1-operation-repository.test.ts && tsx tests/cloudflare-allocation-lifecycle.test.ts && tsx tests/cloudflare-provisioning-api.test.ts && tsx tests/cloudflare-runtime.test.ts && tsx tests/cloudflare-public-reader.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", "test:cloudflare-administrator-claim": "tsx tests/cloudflare-provisioning-api.test.ts", "test:cloudflare-wordpress-auth": "tsx tests/cloudflare-wordpress-auth.test.ts", "test:cloudflare-wordpress-archive-corpus": "tsx tests/cloudflare-wordpress-archive-corpus.test.ts", diff --git a/packages/runtime-cloudflare/README.md b/packages/runtime-cloudflare/README.md index ade4d6ba..9728d942 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -4,6 +4,8 @@ This candidate integration for [wp-codebox#1838](https://github.com/Automattic/w ## Runtime Architecture +The optional `public-reader-worker.ts` entrypoint is an R2-only immutable publication reader. It never imports PHP-WASM, SQLite, D1, revision coordinators, or mutation execution. It serves only complete publication snapshots, WordPress core assets, and revision-bound `wp-content` or uploads; missing or building publication state returns a bounded response rather than falling through to dynamic WordPress. Build it without deployment using `npm run cloudflare:dry-run:public-reader`. + The entry Worker executes PHP-WASM and WordPress. Runtime behavior depends on the typed `RevisionCoordinator` contract rather than a Cloudflare storage product. The standard `worker-do.ts` entrypoint injects the existing `WordPressStateCoordinator` Durable Object adapter. The ChatGPT Sites-compatible `worker-d1.ts` entrypoint injects a D1 adapter that stores only the current pointer, version, lease token, lease base, and expiry in one conditional-update row per site. Both adapters serialize the same bounded lease and CAS promotion semantics; neither imports or instantiates PHP-WASM. MDI, disposable SQLite, and canonical R2 storage are unchanged. On cold start, the entry Worker uses the acquired pointer to rebuild PHP-WASM's disposable SQLite index from canonical MDI Markdown and JSON files. A missing pointer materializes the packaged canonical MDI seed and boots one PHP-WASM primary runtime. The build-time PHP CLI generator creates that archive from `wordpress-install-seed.sqlite` through MDI's public `bootstrap_existing_cache()` API, validates its pinned MDI revision and input digest, and never packages SQLite. The runtime updates `siteurl` and `home` through WordPress APIs using the request origin and sets the admin password from `WORDPRESS_ADMIN_PASSWORD`; only WordPress's password hash is canonical. Bootstrap persists and CAS-promotes this mutation before serving the next request. @@ -63,7 +65,7 @@ SSI is extracted only for import requests, so normal browser, mutation, publicat 1. Run `npm run generate:cloudflare-canonical-mdi-seed` and `npm run generate:cloudflare-wordpress-runtime-corpus` to regenerate deterministic runtime artifacts and manifests. 2. Run `npm run provision:cloudflare-wordpress-runtime-corpus -- --local --persist-to ` to verify and upload all exact content-addressed artifacts into isolated local R2 storage. For an authorized deployment, run the provisioner with `--remote` and require every upload to succeed before deploying the Worker that imports their manifests. 3. Run `npm run test:cloudflare-runtime` for routing, coordinator composition, canonical-state, artifact validation, source contract, and TypeScript coverage. -4. Run `npm run cloudflare:dry-run` and `npm run cloudflare:dry-run:d1` to compile the Durable Object and D1 profiles without creating Cloudflare resources. The placeholder D1 database ID is for local/dry-run verification; an implementation supplies its provisioned binding at deployment. +4. Run `npm run cloudflare:dry-run`, `npm run cloudflare:dry-run:d1`, and `npm run cloudflare:dry-run:public-reader` to compile the Durable Object, D1, and R2-only public-reader profiles without creating Cloudflare resources. The public-reader dry run must report only its R2 binding. The placeholder D1 database ID is for local/dry-run verification; an implementation supplies its provisioned binding at deployment. Use `npm run provision:cloudflare-d1-coordinator -- --output ` to idempotently create or resolve the named production database and emit a mode-`0600` deployment config from the checked-in template. Relative entrypoints are resolved from that template so the emitted config remains deployable outside the checkout. 5. Run `npm run cloudflare:local-gate` and `npm run cloudflare:local-gate:d1` for the same isolated workerd workflow through both coordinator implementations. Each gate generates and provisions all artifacts, verifies the selected backend through the state envelope, injects stable test-only admin-password, auth-secret, and operator-token values, uploads and activates a real plugin ZIP, establishes an anonymous homepage and canonical-permalink publication, updates a published post through authenticated REST without immediate rendering, restarts with pending publication work, and proves bounded scheduled draining eventually exposes the updated permalink through coordinator-free R2. The cron gate also proves that a scheduled post becomes readable from the publication path without an operator call. Restart verification requires the immutable R2 publication. A final two-host phase proves isolated mutations, coordinator versions, REST collections, credentials, publications, and caches plus fail-closed unknown-host routing. The remaining coverage includes login, concurrent canonical writes, media, representative frontend/admin/editor assets, PHP diagnostics, session recovery, and a fresh login after restart. diff --git a/packages/runtime-cloudflare/src/public-reader-worker.ts b/packages/runtime-cloudflare/src/public-reader-worker.ts new file mode 100644 index 00000000..199cddbc --- /dev/null +++ b/packages/runtime-cloudflare/src/public-reader-worker.ts @@ -0,0 +1,3 @@ +import { createCloudflarePublicReader, type PublicReaderEnv } from "./public-reader.js" + +export default createCloudflarePublicReader() diff --git a/packages/runtime-cloudflare/src/public-reader.ts b/packages/runtime-cloudflare/src/public-reader.ts new file mode 100644 index 00000000..ec4a6b1a --- /dev/null +++ b/packages/runtime-cloudflare/src/public-reader.ts @@ -0,0 +1,167 @@ +import { canonicalPublicRoute, MAX_PUBLISHED_PAGE_BYTES, MAX_PUBLISHED_REVISION_BYTES, PUBLISHED_PAGE_SCHEMA, validateCompletePublishedRevision } from "./published-reader.js" +import { parseSiteContexts, previewDomain, resolvePreviewSiteContextFromRequest, resolveSiteContextFromRequest, siteStorageKeys, type SiteContext } from "./site-context.js" +import { isCanonicalWpContentPath, validateWpContentManifestFiles } from "./wp-content-persistence.js" +import { validateUploadManifestFiles } from "./upload-persistence.js" +import { servePublicWordPressStaticAsset } from "./wordpress-static-reader.js" + +export interface PublicReaderEnv { + WORDPRESS_STATE_BUCKET: R2Bucket + WORDPRESS_SITE_CONTEXTS?: string + WORDPRESS_PREVIEW_DOMAIN?: string + WORDPRESS_PREVIEW_HOST_SECRET?: string +} + +interface PageSnapshot { + schema: typeof PUBLISHED_PAGE_SCHEMA + canonicalRevision: string + route: string + status: number + statusText: string + headers: Array<[string, string]> + body: string +} + +interface PublicationAsset { + path: string + objectKey: string + sha256: string + size: number +} + +/** + * This is intentionally a complete request boundary: it has no mutation + * fallback, so an absent or incomplete publication cannot boot WordPress. + */ +export function createCloudflarePublicReader() { + return { + async fetch(request: Request, env: Env): Promise { + let site: SiteContext + try { + const contexts = parseSiteContexts(env.WORDPRESS_SITE_CONTEXTS) + try { + site = resolveSiteContextFromRequest(request, contexts) + } catch (error) { + if (!(error instanceof Error) || error.message !== "Unknown site hostname.") throw error + site = await resolvePreviewSiteContextFromRequest(request, previewDomain(env.WORDPRESS_PREVIEW_DOMAIN, env.WORDPRESS_PREVIEW_HOST_SECRET)) + } + } catch (error) { + if (error instanceof Error && error.message === "Unknown site hostname.") return response("Unknown site hostname.", 421) + return response("Public publication configuration is invalid.", 503) + } + return servePublicPublication(request, env.WORDPRESS_STATE_BUCKET, site) + }, + } +} + +export async function servePublicPublication(request: Request, bucket: R2Bucket, site: SiteContext): Promise { + if (request.method !== "GET" && request.method !== "HEAD") return response("Public publication accepts GET and HEAD only.", 405) + if (request.headers.has("authorization") || request.headers.has("cookie") || new URL(request.url).searchParams.has("phase")) return response("Dynamic WordPress requests are unavailable from the public reader.", 404) + const staticAsset = await servePublicWordPressStaticAsset(request, bucket) + if (staticAsset) return staticAsset + const route = canonicalPublicRoute(request) + let publication + try { + const current = await bucket.get(siteStorageKeys(site).publishedCurrent) + if (!current) return response("Public publication is not ready.", 503) + if (current.size > MAX_PUBLISHED_REVISION_BYTES) return response("Public publication is incomplete.", 503) + publication = validateCompletePublishedRevision(JSON.parse(await current.text()), site) + } catch { + return response("Public publication is incomplete.", 503) + } + const asset = await servePublishedAsset(request, bucket, site, publication) + if (asset) return asset + const entry = publication.routes.find((candidate) => candidate.route === route) + if (!entry) return response("Published route not found.", 404) + + const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default + const cacheKey = new Request(`https://wp-codebox-publication.invalid/${site.id}/${publication.revision}${route}`, { method: "GET" }) + const cached = cache ? await cache.match(cacheKey) : null + if (cached) return publicationResponse(cached, request.method === "HEAD", publication.revision, "edge") + try { + const object = await bucket.get(entry.objectKey) + if (!object || object.size > MAX_PUBLISHED_PAGE_BYTES) return response("Public publication is incomplete.", 503) + const snapshot = JSON.parse(await object.text()) as PageSnapshot + if (!isPageSnapshot(snapshot, entry.canonicalRevision, route)) return response("Public publication is incomplete.", 503) + const published = publicationResponse(new Response(snapshot.body, { status: snapshot.status, statusText: snapshot.statusText, headers: snapshot.headers }), request.method === "HEAD", publication.revision, "r2") + if (cache && request.method === "GET") await cache.put(cacheKey, published.clone()).catch(() => undefined) + return published + } catch { + return response("Public publication is incomplete.", 503) + } +} + +async function servePublishedAsset(request: Request, bucket: R2Bucket, site: SiteContext, publication: ReturnType): Promise { + const pathname = new URL(request.url).pathname + const uploads = pathname.startsWith("/wp-content/uploads/") ? pathname.slice("/wp-content/uploads/".length) : null + const wpContent = pathname.startsWith("/wp-content/") ? pathname.slice("/wp-content/".length) : null + if (uploads === null && wpContent === null) return null + let path: string + try { + path = decodeURIComponent(uploads ?? wpContent!) + } catch { + return response("Published asset path is invalid.", 400) + } + if ((uploads === null && !isCanonicalWpContentPath(path)) || (uploads !== null && !isCanonicalRelativePath(path))) return response("Published asset path is invalid.", 400) + try { + const manifestKey = `${siteStorageKeys(site).markdownRevisionPrefix}/${publication.canonicalRevision}.json` + const manifestObject = await bucket.get(manifestKey) + if (!manifestObject) return response("Public publication is incomplete.", 503) + const manifest = await manifestObject.json<{ revision?: unknown; uploads?: PublicationAsset[]; wpContent?: PublicationAsset[] }>() + if (manifest.revision !== publication.canonicalRevision) return response("Public publication is incomplete.", 503) + const files = uploads === null ? manifest.wpContent ?? [] : manifest.uploads ?? [] + if (uploads === null) validateWpContentManifestFiles(files, site) + else validateUploadManifestFiles(files, site) + const file = files.find((candidate) => candidate.path === path) + if (!file) return response("Published asset not found.", 404) + const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default + const cacheKey = new Request(`https://wp-codebox-publication.invalid/${site.id}/${publication.revision}/${file.sha256}/${pathname}`, { method: "GET" }) + const cached = cache ? await cache.match(cacheKey) : null + if (cached) return assetResponse(cached, request.method === "HEAD", publication.revision, "edge") + const object = await bucket.get(file.objectKey) + if (!object || object.size !== file.size) return response("Public publication is incomplete.", 503) + const body = request.method === "HEAD" ? null : new Uint8Array(await object.arrayBuffer()) + if (body && await sha256Hex(body) !== file.sha256) return response("Public publication is incomplete.", 503) + const published = assetResponse(new Response(body ? Uint8Array.from(body).buffer : null, { headers: { "content-length": String(file.size), "content-type": object.httpMetadata?.contentType ?? "application/octet-stream", etag: `\"${file.sha256}\"` } }), request.method === "HEAD", publication.revision, "r2") + if (cache && request.method === "GET") await cache.put(cacheKey, published.clone()).catch(() => undefined) + return published + } catch { + return response("Public publication is incomplete.", 503) + } +} + +function isPageSnapshot(snapshot: PageSnapshot, canonicalRevision: string, route: string): boolean { + return snapshot.schema === PUBLISHED_PAGE_SCHEMA && snapshot.canonicalRevision === canonicalRevision && snapshot.route === route + && Number.isInteger(snapshot.status) && snapshot.status >= 100 && snapshot.status <= 599 && typeof snapshot.statusText === "string" + && Array.isArray(snapshot.headers) && snapshot.headers.every((header) => Array.isArray(header) && header.length === 2 && header.every((value) => typeof value === "string")) + && typeof snapshot.body === "string" +} + +function publicationResponse(response: Response, head: boolean, revision: string, source: "edge" | "r2"): Response { + const headers = new Headers(response.headers) + // The Worker cache key includes this immutable publication revision and site generation. + headers.set("cache-control", "public, max-age=60, s-maxage=60") + headers.set("x-wp-codebox-publication-revision", revision) + headers.set("x-wp-codebox-publication-cache", source) + return new Response(head ? null : response.body, { status: response.status, statusText: response.statusText, headers }) +} + +function response(message: string, status: number): Response { + return new Response(message, { status, headers: { "cache-control": "no-store", "x-wp-codebox-public-reader": "bounded" } }) +} + +function isCanonicalRelativePath(path: string): boolean { + return path.length > 0 && path.length <= 2_048 && !path.startsWith("/") && !path.includes("\\") && path.split("/").every((part) => part !== "" && part !== "." && part !== "..") +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} + +function assetResponse(response: Response, head: boolean, revision: string, source: "edge" | "r2"): Response { + const headers = new Headers(response.headers) + headers.set("cache-control", "public, max-age=60, s-maxage=60") + headers.set("x-wp-codebox-publication-revision", revision) + headers.set("x-wp-codebox-publication-asset", source) + return new Response(head ? null : response.body, { status: response.status, headers }) +} diff --git a/packages/runtime-cloudflare/src/published-reader.ts b/packages/runtime-cloudflare/src/published-reader.ts index fb890199..ce6aca2c 100644 --- a/packages/runtime-cloudflare/src/published-reader.ts +++ b/packages/runtime-cloudflare/src/published-reader.ts @@ -1,5 +1,6 @@ -export const PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v3" as const -const PREVIOUS_PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v2" +export const PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v4" as const +const PREVIOUS_PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v3" +const OLDER_PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v2" const LEGACY_PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v1" export const PUBLISHED_PAGE_SCHEMA = "wp-codebox/wordpress-page/v2" as const export const R2_PUBLISHED_CURRENT_KEY = siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent @@ -16,6 +17,7 @@ export interface PublishedRoute { export interface PublishedRevision { schema: typeof PUBLISHED_REVISION_SCHEMA + state: "building" | "complete" revision: string canonicalRevision: string canonicalVersion: number @@ -47,9 +49,9 @@ export function validatePublishedRevision(value: unknown, site: SiteContext = DE const revision = value as Partial const legacy = (value as { schema?: unknown }).schema === LEGACY_PUBLISHED_REVISION_SCHEMA const current = revision.schema === PUBLISHED_REVISION_SCHEMA - if (![PUBLISHED_REVISION_SCHEMA, PREVIOUS_PUBLISHED_REVISION_SCHEMA, LEGACY_PUBLISHED_REVISION_SCHEMA].includes(revision.schema as string) || !isRevision(revision.revision) || !isRevision(revision.canonicalRevision) + if (![PUBLISHED_REVISION_SCHEMA, PREVIOUS_PUBLISHED_REVISION_SCHEMA, OLDER_PUBLISHED_REVISION_SCHEMA, LEGACY_PUBLISHED_REVISION_SCHEMA].includes(revision.schema as string) || !isRevision(revision.revision) || !isRevision(revision.canonicalRevision) || typeof revision.publishedAt !== "string" || !Number.isFinite(Date.parse(revision.publishedAt)) || !Array.isArray(revision.routes) - || (current && (typeof revision.canonicalVersion !== "number" || !Number.isSafeInteger(revision.canonicalVersion) || revision.canonicalVersion < 0)) + || (current && (!["building", "complete"].includes(revision.state as string) || (revision.state === "complete" && revision.routes.length === 0) || typeof revision.canonicalVersion !== "number" || !Number.isSafeInteger(revision.canonicalVersion) || revision.canonicalVersion < 0)) || (revision.sourceJob !== undefined && (typeof revision.sourceJob !== "string" || !new RegExp(`^${escapeRegExp(siteStorageKeys(site).publicationJobPrefix)}/[0-9]{20}-[a-f0-9-]{36}\\.json$`).test(revision.sourceJob))) || revision.routes.length > MAX_PUBLISHED_ROUTES) throw new Error("Published revision is invalid.") let previous = "" @@ -63,7 +65,16 @@ export function validatePublishedRevision(value: unknown, site: SiteContext = DE previous = route.route normalizedRoutes.push({ route: route.route, objectKey: route.objectKey, canonicalRevision: routeRevision }) } - return { ...revision, schema: PUBLISHED_REVISION_SCHEMA, canonicalVersion: current ? revision.canonicalVersion! : 0, routes: normalizedRoutes } as PublishedRevision + // Legacy publications remain readable by the existing mutation Worker. The + // isolated public reader uses the strict validator below. + return { ...revision, schema: PUBLISHED_REVISION_SCHEMA, state: current ? revision.state! : "complete", canonicalVersion: current ? revision.canonicalVersion! : 0, routes: normalizedRoutes } as PublishedRevision +} + +export function validateCompletePublishedRevision(value: unknown, site: SiteContext = DEFAULT_SITE_CONTEXT): PublishedRevision & { state: "complete" } { + if (!value || typeof value !== "object" || (value as Partial).schema !== PUBLISHED_REVISION_SCHEMA || (value as Partial).state !== "complete") { + throw new Error("Published revision is not ready for isolated reads.") + } + return validatePublishedRevision(value, site) as PublishedRevision & { state: "complete" } } export function normalizePublishedRoutes(value: unknown): string[] { diff --git a/packages/runtime-cloudflare/src/wordpress-static-reader.ts b/packages/runtime-cloudflare/src/wordpress-static-reader.ts new file mode 100644 index 00000000..c8406d5f --- /dev/null +++ b/packages/runtime-cloudflare/src/wordpress-static-reader.ts @@ -0,0 +1,59 @@ +import wordpressStaticArtifactManifest from "../assets/wordpress-static-artifact.json" with { type: "json" } +import { wordpressStaticArchivePath, wordpressStaticContentType } from "./wordpress-runtime-corpus.js" +import { validateWordPressStaticArtifactManifest, type WordPressStaticArtifactManifest } from "./wordpress-static-artifact.js" + +export const wordpressStaticArtifact = wordpressStaticArtifactManifest as WordPressStaticArtifactManifest +validateWordPressStaticArtifactManifest(wordpressStaticArtifact) + +const wordpressStaticFiles = new Map(wordpressStaticArtifact.files.map((file) => [file.path, file])) + +export async function servePublicWordPressStaticAsset(request: Request, bucket: R2Bucket): Promise { + if (request.method !== "GET" && request.method !== "HEAD") return null + const archivePath = wordpressStaticArchivePath(new URL(request.url).pathname) + if (!archivePath) return null + const file = wordpressStaticFiles.get(archivePath) + if (!file) return null + + const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default + const cacheRequest = new Request(`https://wp-codebox-static.invalid/${wordpressStaticArtifact.blob.sha256}/${archivePath}`, { method: "GET" }) + if (cache) { + try { + const cached = await cache.match(cacheRequest) + if (cached) return staticAssetResponse(cached, request.method === "HEAD", "edge") + } catch { + // R2 remains authoritative when the Worker cache is unavailable. + } + } + + const object = file.size ? await bucket.get(wordpressStaticArtifact.key, { range: { offset: file.offset, length: file.size } }) : undefined + if (file.size && !object) return new Response("WordPress static artifact is unavailable.", { status: 503, headers: { "cache-control": "no-store" } }) + const bytes = object ? new Uint8Array(await object.arrayBuffer()) : new Uint8Array() + if (bytes.byteLength !== file.size || await sha256Hex(bytes) !== file.sha256) return new Response("WordPress static artifact integrity check failed.", { status: 502, headers: { "cache-control": "no-store" } }) + const response = staticAssetResponse(new Response(bytes, { + headers: { + "content-length": String(file.size), + "content-type": wordpressStaticContentType(archivePath), + etag: `"${file.sha256}"`, + }, + }), request.method === "HEAD", "r2") + if (request.method === "GET" && cache) { + try { + await cache.put(cacheRequest, response.clone()) + } catch { + // R2 remains authoritative when the Worker cache is unavailable. + } + } + return response +} + +function staticAssetResponse(response: Response, head: boolean, source: "edge" | "r2"): Response { + const headers = new Headers(response.headers) + headers.set("cache-control", "public, max-age=31536000, immutable") + headers.set("x-wp-codebox-static", `${source}-artifact`) + return new Response(head ? null : response.body, { status: response.status, statusText: response.statusText, headers }) +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index fb4dbba3..4867f04b 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -734,6 +734,7 @@ async function publishCanonicalWordPressPages(request: Request, env: RuntimeEnv, })) const publication: PublishedRevision = { schema: PUBLISHED_REVISION_SCHEMA, + state: "complete", revision: crypto.randomUUID(), canonicalRevision: lease.pointer.revision, canonicalVersion: lease.version, @@ -769,6 +770,7 @@ async function initializeProvisioningPublication(bucket: R2Bucket, lease: Lease, if (!lease.pointer) throw new Error("Provisioning publication requires a committed canonical revision.") const publication: PublishedRevision = { schema: PUBLISHED_REVISION_SCHEMA, + state: "building", revision: crypto.randomUUID(), canonicalRevision: lease.pointer.revision, canonicalVersion: lease.version, @@ -1027,7 +1029,7 @@ async function drainNextPublicationJobWhileLeased(env: RuntimeEnv, coordinator: const routes = new Map(current.publication.routes.map((route) => [route.route, route])) for (const route of plan.remove) routes.delete(route) for (const route of plan.upsert) routes.set(route, { route, objectKey: await publishedPageObjectKey(job.canonical.revision, route, site), canonicalRevision: job.canonical.revision }) - const publication: PublishedRevision = { schema: PUBLISHED_REVISION_SCHEMA, revision: crypto.randomUUID(), canonicalRevision: job.canonical.revision, canonicalVersion: job.coordinatorVersion, sourceJob: job.key, publishedAt: new Date().toISOString(), routes: [...routes.values()].sort((left, right) => left.route.localeCompare(right.route)) } + const publication: PublishedRevision = { schema: PUBLISHED_REVISION_SCHEMA, state: "complete", revision: crypto.randomUUID(), canonicalRevision: job.canonical.revision, canonicalVersion: job.coordinatorVersion, sourceJob: job.key, publishedAt: new Date().toISOString(), routes: [...routes.values()].sort((left, right) => left.route.localeCompare(right.route)) } const serialized = JSON.stringify(publication) if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_REVISION_BYTES) throw new Error("Incremental publication exceeds its size budget.") await renewLease() @@ -2695,7 +2697,6 @@ async function serveWordPressStaticAsset(request: Request, bucket: R2Bucket): Pr return response } - function createPhpRuntime() { return loadPHPRuntime( { dependencyFilename: "php_8_5.wasm", dependenciesTotalSize, phpWasmAsyncMode: "asyncify", init }, diff --git a/packages/runtime-cloudflare/wrangler.public-reader.jsonc b/packages/runtime-cloudflare/wrangler.public-reader.jsonc new file mode 100644 index 00000000..c669b73b --- /dev/null +++ b/packages/runtime-cloudflare/wrangler.public-reader.jsonc @@ -0,0 +1,10 @@ +// Public immutable publication reader. It intentionally has no PHP-WASM, D1, or Durable Object bindings. +{ + "name": "wp-codebox-cloudflare-public-reader", + "main": "src/public-reader-worker.ts", + "compatibility_date": "2026-07-18", + "compatibility_flags": ["nodejs_compat"], + "r2_buckets": [ + { "binding": "WORDPRESS_STATE_BUCKET", "bucket_name": "wp-codebox-runtime-chubes" } + ] +} diff --git a/tests/cloudflare-public-reader.test.ts b/tests/cloudflare-public-reader.test.ts new file mode 100644 index 00000000..9ee6dbab --- /dev/null +++ b/tests/cloudflare-public-reader.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { createHash } from "node:crypto" +import { PUBLISHED_PAGE_SCHEMA, PUBLISHED_REVISION_SCHEMA, publishedPageObjectKey } from "../packages/runtime-cloudflare/src/published-reader.js" +import { servePublicPublication } from "../packages/runtime-cloudflare/src/public-reader.js" +import { DEFAULT_SITE_CONTEXT, siteStorageKeys } from "../packages/runtime-cloudflare/src/site-context.js" + +const canonicalRevision = "11111111-1111-4111-8111-111111111111" +const publicationRevision = "22222222-2222-4222-8222-222222222222" + +function bucket(objects: Map) { + return { + async get(key: string) { + const value = objects.get(key) + if (value === undefined) return null + const bytes = typeof value === "string" ? new TextEncoder().encode(value) : value + return { + size: bytes.byteLength, + etag: "test", + httpMetadata: { contentType: "text/css" }, + text: async () => new TextDecoder().decode(bytes), + json: async () => JSON.parse(new TextDecoder().decode(bytes)) as T, + arrayBuffer: async () => bytes.slice().buffer, + } + }, + } +} + +async function completePublication(route = "/") { + const objectKey = await publishedPageObjectKey(canonicalRevision, route, DEFAULT_SITE_CONTEXT) + return { schema: PUBLISHED_REVISION_SCHEMA, state: "complete", revision: publicationRevision, canonicalRevision, canonicalVersion: 9, publishedAt: "2026-07-26T00:00:00.000Z", routes: [{ route, objectKey, canonicalRevision }] } +} + +test("public reader serves complete immutable routes without mutation dependencies", async () => { + const publication = await completePublication() + const objectKey = publication.routes[0].objectKey + const objects = new Map([ + [siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent, JSON.stringify(publication)], + [objectKey, JSON.stringify({ schema: PUBLISHED_PAGE_SCHEMA, canonicalRevision, route: "/", status: 200, statusText: "OK", headers: [["content-type", "text/html"]], body: "

Published

" })], + ]) + const response = await servePublicPublication(new Request("https://wp-codebox-cloudflare-runtime.chubes.workers.dev/"), bucket(objects) as never, DEFAULT_SITE_CONTEXT) + assert.equal(response.status, 200) + assert.equal(await response.text(), "

Published

") + assert.equal(response.headers.get("x-wp-codebox-publication-revision"), publicationRevision) + assert.match(response.headers.get("cache-control")!, /s-maxage=60/) +}) + +test("public reader bounds missing and incomplete publications without dynamic fallback", async () => { + const empty = await servePublicPublication(new Request("https://reader.example/missing"), bucket(new Map()) as never, DEFAULT_SITE_CONTEXT) + assert.equal(empty.status, 503) + const publication = await completePublication() + const missing = await servePublicPublication(new Request("https://reader.example/missing"), bucket(new Map([[siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent, JSON.stringify(publication)]])) as never, DEFAULT_SITE_CONTEXT) + assert.equal(missing.status, 404) + const incomplete = { ...publication, state: "building" } + const invalid = await servePublicPublication(new Request("https://reader.example/"), bucket(new Map([[siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent, JSON.stringify(incomplete)]])) as never, DEFAULT_SITE_CONTEXT) + assert.equal(invalid.status, 503) + assert.equal(invalid.headers.get("x-wp-codebox-public-reader"), "bounded") + + const emptyComplete = { ...publication, routes: [] } + const emptyCompleteResponse = await servePublicPublication(new Request("https://reader.example/"), bucket(new Map([[siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent, JSON.stringify(emptyComplete)]])) as never, DEFAULT_SITE_CONTEXT) + assert.equal(emptyCompleteResponse.status, 503) + + const legacy = { ...publication, schema: "wp-codebox/published-revision/v3" } + const legacyResponse = await servePublicPublication(new Request("https://reader.example/"), bucket(new Map([[siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent, JSON.stringify(legacy)]])) as never, DEFAULT_SITE_CONTEXT) + assert.equal(legacyResponse.status, 503) +}) + +test("public reader serves publication-bound assets with revision-safe identity", async () => { + const publication = await completePublication() + const css = new TextEncoder().encode("body { color: green; }") + const sha256 = createHash("sha256").update(css).digest("hex") + const assetKey = `${siteStorageKeys(DEFAULT_SITE_CONTEXT).wpContentObjectPrefix}/${sha256}` + const manifestKey = `${siteStorageKeys(DEFAULT_SITE_CONTEXT).markdownRevisionPrefix}/${canonicalRevision}.json` + const objects = new Map([ + [siteStorageKeys(DEFAULT_SITE_CONTEXT).publishedCurrent, JSON.stringify(publication)], + [manifestKey, JSON.stringify({ revision: canonicalRevision, wpContent: [{ path: "themes/example/style.css", objectKey: assetKey, sha256, size: css.byteLength }] })], + [assetKey, css], + ]) + const response = await servePublicPublication(new Request("https://reader.example/wp-content/themes/example/style.css"), bucket(objects) as never, DEFAULT_SITE_CONTEXT) + assert.equal(response.status, 200) + assert.equal(await response.text(), "body { color: green; }") + assert.equal(response.headers.get("x-wp-codebox-publication-asset"), "r2") + assert.equal(response.headers.get("x-wp-codebox-publication-revision"), publicationRevision) +}) + +test("public reader serves WordPress core assets required by published HTML", async () => { + const response = await servePublicPublication(new Request("https://reader.example/wp-includes/js/swfobject.js"), bucket(new Map()) as never, DEFAULT_SITE_CONTEXT) + assert.equal(response.status, 200) + assert.equal(response.headers.get("content-type"), "text/javascript; charset=utf-8") + assert.equal(response.headers.get("x-wp-codebox-static"), "r2-artifact") +}) diff --git a/tests/cloudflare-runtime.test.ts b/tests/cloudflare-runtime.test.ts index 4289ae13..bd0a6ed5 100644 --- a/tests/cloudflare-runtime.test.ts +++ b/tests/cloudflare-runtime.test.ts @@ -122,6 +122,7 @@ test("Cloudflare publication contracts are host-independent, immutable, and boun assert.equal(objectKey, await publishedPageObjectKey(canonicalRevision, canonicalPublicRoute("https://another-host.example/"))) const publication = validatePublishedRevision({ schema: PUBLISHED_REVISION_SCHEMA, + state: "complete", revision: publicationRevision, canonicalRevision, canonicalVersion: 7, @@ -129,12 +130,13 @@ test("Cloudflare publication contracts are host-independent, immutable, and boun routes: [{ route: "/", objectKey, canonicalRevision }], }) assert.equal(publication.routes[0].objectKey, objectKey) - assert.equal(PUBLISHED_REVISION_SCHEMA, "wp-codebox/published-revision/v3") + assert.equal(PUBLISHED_REVISION_SCHEMA, "wp-codebox/published-revision/v4") assert.equal(publishedRevisionObjectKey(publicationRevision), `sites/default/publications/revisions/${publicationRevision}.json`) assert.equal(R2_PUBLISHED_CURRENT_KEY, "sites/default/publications/current.json") assert.equal(validatePublishedRevision({ ...publication, schema: "wp-codebox/published-revision/v1", routes: [{ route: "/", objectKey }] }).routes[0].canonicalRevision, canonicalRevision) assert.equal(validatePublishedRevision({ ...publication, schema: "wp-codebox/published-revision/v2", canonicalVersion: undefined }).canonicalVersion, 0) - assert.deepEqual(validatePublishedRevision({ ...publication, routes: [] }).routes, []) + assert.throws(() => validatePublishedRevision({ ...publication, routes: [] }), /invalid/) + assert.deepEqual(validatePublishedRevision({ ...publication, state: "building", routes: [] }).routes, []) const sourceJob = `sites/default/publications/jobs/00000000000000000007-${canonicalRevision}.json` assert.equal(validatePublishedRevision({ ...publication, sourceJob }).sourceJob, sourceJob) assert.throws(() => validatePublishedRevision({ ...publication, sourceJob: "sites/default/publications/jobs/foreign.json" }), /invalid/)