Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion packages/runtime-cloudflare/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 <directory>` 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 <production-config>` 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.

Expand Down
3 changes: 3 additions & 0 deletions packages/runtime-cloudflare/src/public-reader-worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { createCloudflarePublicReader, type PublicReaderEnv } from "./public-reader.js"

export default createCloudflarePublicReader<PublicReaderEnv>()
167 changes: 167 additions & 0 deletions packages/runtime-cloudflare/src/public-reader.ts
Original file line number Diff line number Diff line change
@@ -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<Env extends PublicReaderEnv>() {
return {
async fetch(request: Request, env: Env): Promise<Response> {
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<Response> {
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<typeof validateCompletePublishedRevision>): Promise<Response | null> {
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<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>)
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 })
}
Loading
Loading