diff --git a/package.json b/package.json index 6198c1999..5544f5241 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,9 @@ "scripts": { "build": "node ./node_modules/typescript/bin/tsc -b 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:local-gate": "node scripts/cloudflare-local-gate.mjs", + "cloudflare:local-gate:d1": "node scripts/cloudflare-local-gate.mjs --coordinator=d1", "postinstall": "patch-package", "generate:cloudflare-wordpress-runtime-corpus": "tsx scripts/generate-cloudflare-wordpress-runtime-corpus.ts", "provision:cloudflare-wordpress-runtime-corpus": "node scripts/provision-cloudflare-wordpress-runtime-corpus.mjs", diff --git a/packages/runtime-cloudflare/README.md b/packages/runtime-cloudflare/README.md index 52a22bd97..0a625680c 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -4,7 +4,7 @@ This candidate integration for [wp-codebox#1838](https://github.com/Automattic/w ## Runtime Architecture -The entry Worker executes PHP-WASM and WordPress. The named `WordPressStateCoordinator` Durable Object remains lightweight: it serializes a bounded lease and atomically promotes the current canonical R2 pointer with token, base-revision, and version checks. It never imports or instantiates PHP-WASM. State reads query the coordinator directly; every request that can observe canonical WordPress state acquires a lease first. +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. 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. @@ -12,6 +12,8 @@ Canonical browser, health, and mutation boots require the separately managed `WO After each mutating HTTP request, the entry runtime invokes MDI's explicit request-boundary flush, collects canonical files, stores immutable content-addressed R2 objects and a revision manifest, then commits the new pointer through the held lease. GET, HEAD, and asset requests release without promotion. Failed requests abort their leases; stale leases recover by token/version/expiry checks. The entry isolate can cache one runtime only for the exact acquired pointer revision and exits it after promotion or when another isolate advances the pointer. It does not persist SQLite. Existing manifests are reused when canonical file hashes have not changed. +Anonymous HTML responses are stored as create-once, host-independent R2 artifacts under their canonical revision. An authorized `POST ?phase=operator-publish` promotes a bounded, sorted set of already-rendered routes by writing an immutable publication descriptor and then replacing one strongly consistent `sites/default/publications/current.json` pointer. Published GET and HEAD requests check a 60-second edge entry and then the R2 publication before constructing the Durable Object stub; authenticated, preview, admin, REST, and unpublished routes continue through WordPress. Promotion rejects missing, stale, duplicate, malformed, or conflicting artifacts. This is the coordinator-free reader boundary, not yet a complete route compiler or global CDN purge pipeline. + The same revision transaction persists bounded user-managed files under `wp-content/plugins`, `themes`, `languages`, and `mu-plugins`. Runtime-owned MDI, SQLite integration, and Codebox adapter files remain reconstructable artifacts and are excluded. Unchanged bundled-theme files are omitted by release hash, while modified files become canonical overrides. Public canonical plugin and theme assets serve directly from revision-addressed R2 cache entries before the immutable release corpus. Nonce-protected `wp-admin` GET actions such as plugin activation are classified as mutations so their filesystem and MDI option changes commit atomically. Existing revisions without `wpContent` remain valid. The Worker forwards browser cookies directly to Playground and disables Playground's internal cookie store, preventing an empty per-isolate store from replacing a valid browser session after cold restart. @@ -27,8 +29,8 @@ Canonical Cloudflare boots patch only the assembled PHP MEMFS copy of `/wordpres 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, canonical-state, artifact validation, source contract, and TypeScript coverage. -4. Run `npm run cloudflare:dry-run` to compile the Worker without creating Cloudflare resources. -5. Run `npm run cloudflare:local-gate` for isolated local workerd evidence. It generates and provisions all artifacts before workerd starts, then injects stable test-only admin-password and auth-secret values, uploads and activates a real plugin ZIP, verifies that plugin's REST route before and after Worker restart, and covers login, authenticated REST publication, media, public rendering, representative frontend/admin/editor assets, PHP diagnostics, session recovery, and a fresh login after restart. +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. +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, promotes anonymous homepage and canonical-permalink artifacts, and proves an R2 publication read after Worker restart. They also cover login, concurrent canonical writes, authenticated REST publication, media, representative frontend/admin/editor assets, PHP diagnostics, session recovery, cron, and a fresh login after restart. This document describes local candidate verification only. It does not claim remote deployment. diff --git a/packages/runtime-cloudflare/src/d1-revision-coordinator.ts b/packages/runtime-cloudflare/src/d1-revision-coordinator.ts new file mode 100644 index 000000000..0767e19b2 --- /dev/null +++ b/packages/runtime-cloudflare/src/d1-revision-coordinator.ts @@ -0,0 +1,163 @@ +import { RevisionConflict, type MarkdownPointer, type RevisionCoordinator, type RevisionLease, type RevisionState } from "./revision-coordinator.js" + +interface StateRow { + revision: string | null + manifest_key: string | null + persisted_at: string | null + version: number + lease_token: string | null + lease_base_revision: string | null + lease_version: number | null + lease_expires_at: number | null +} + +const SITE_ID = "default" +const LEASE_MS = 90_000 +const schemaReady = new WeakMap>() + +export class D1RevisionCoordinator implements RevisionCoordinator { + constructor(private readonly database: D1Database, private readonly leaseMs = LEASE_MS) {} + + state(): Promise { + return readWordPressState(this.database) + } + + acquire(): Promise { + return beginStateLease(this.database, this.leaseMs) + } + + async release(lease: RevisionLease): Promise { + await releaseStateLease(this.database, lease) + } + + async abort(lease: RevisionLease): Promise { + await abortStateLease(this.database, lease) + } + + commit(lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> { + return commitStateLease(this.database, lease, pointer) + } + + async reset(): Promise { + await resetWordPressState(this.database) + } +} + +async function readWordPressState(database: D1Database): Promise { + await ensureSchema(database) + const row = await readRow(database) + return { + schema: "wp-codebox/cloudflare-wordpress-state/v2", + store: "d1", + pointer: pointerFromRow(row), + version: row.version, + } +} + +async function beginStateLease(database: D1Database, leaseMs = LEASE_MS): Promise { + await ensureSchema(database) + const now = Date.now() + const token = crypto.randomUUID() + const expiresAt = now + leaseMs + const result = await database.prepare(`UPDATE wp_codebox_state + SET lease_token = ?, lease_base_revision = revision, lease_version = version, lease_expires_at = ? + WHERE site_id = ? AND (lease_token IS NULL OR lease_expires_at <= ?)`) + .bind(token, expiresAt, SITE_ID, now).run() + if (result.meta.changes !== 1) { + const active = await readRow(database) + throw new RevisionConflict("A canonical WordPress lease is active.", active.lease_expires_at ?? undefined) + } + const row = await readRow(database) + if (row.lease_token !== token || row.lease_version === null) throw new RevisionConflict("The canonical WordPress lease was not acquired.") + return { token, pointer: pointerFromRow(row), version: row.lease_version, expiresAt } +} + +async function releaseStateLease(database: D1Database, lease: RevisionLease): Promise<{ released: true }> { + await finishLease(database, lease, "release") + return { released: true } +} + +async function abortStateLease(database: D1Database, lease: RevisionLease): Promise<{ aborted: true }> { + await finishLease(database, lease, "abort") + return { aborted: true } +} + +async function commitStateLease(database: D1Database, lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> { + validatePointer(pointer) + await ensureSchema(database) + const baseRevision = lease.pointer?.revision ?? null + const result = await database.prepare(`UPDATE wp_codebox_state + SET revision = ?, manifest_key = ?, persisted_at = ?, version = version + 1, + lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL + WHERE site_id = ? AND lease_token = ? AND lease_expires_at > ? AND version = ? AND lease_version = ? + AND ((revision IS NULL AND ? IS NULL) OR revision = ?) + AND ((lease_base_revision IS NULL AND ? IS NULL) OR lease_base_revision = ?)`) + .bind(pointer.revision, pointer.manifestKey, pointer.persistedAt, SITE_ID, lease.token, Date.now(), lease.version, lease.version, + baseRevision, baseRevision, baseRevision, baseRevision).run() + if (result.meta.changes !== 1) throw new RevisionConflict("The canonical pointer changed before D1 promotion.") + return { pointer, version: lease.version + 1 } +} + +async function resetWordPressState(database: D1Database): Promise<{ reset: true }> { + await ensureSchema(database) + await database.prepare(`UPDATE wp_codebox_state + SET revision = NULL, manifest_key = NULL, persisted_at = NULL, version = version + 1, + lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL + WHERE site_id = ?`).bind(SITE_ID).run() + return { reset: true } +} + +async function finishLease(database: D1Database, lease: RevisionLease, action: "release" | "abort"): Promise { + await ensureSchema(database) + const result = await database.prepare(`UPDATE wp_codebox_state + SET lease_token = NULL, lease_base_revision = NULL, lease_version = NULL, lease_expires_at = NULL + WHERE site_id = ? AND lease_token = ? AND lease_expires_at > ?`) + .bind(SITE_ID, lease.token, Date.now()).run() + if (result.meta.changes !== 1) throw new RevisionConflict(`The canonical WordPress lease cannot ${action} because it expired or changed.`) +} + +async function readRow(database: D1Database): Promise { + const row = await database.prepare(`SELECT revision, manifest_key, persisted_at, version, + lease_token, lease_base_revision, lease_version, lease_expires_at + FROM wp_codebox_state WHERE site_id = ?`).bind(SITE_ID).first() + if (!row) throw new Error("D1 WordPress state row is unavailable.") + return row +} + +function pointerFromRow(row: StateRow): MarkdownPointer | null { + if (row.revision === null && row.manifest_key === null && row.persisted_at === null) return null + const pointer = { revision: row.revision, manifestKey: row.manifest_key, persistedAt: row.persisted_at } + validatePointer(pointer) + return pointer as MarkdownPointer +} + +function validatePointer(pointer: unknown): asserts pointer is MarkdownPointer { + if (!pointer || typeof pointer !== "object") throw new RevisionConflict("A complete canonical pointer is required for D1 promotion.") + const candidate = pointer as Partial + if (typeof candidate.revision !== "string" || typeof candidate.manifestKey !== "string" || typeof candidate.persistedAt !== "string") { + throw new RevisionConflict("A complete canonical pointer is required for D1 promotion.") + } +} + +function ensureSchema(database: D1Database): Promise { + const key = database as object + const existing = schemaReady.get(key) + if (existing) return existing + const pending = (async () => { + await database.prepare(`CREATE TABLE IF NOT EXISTS wp_codebox_state ( + site_id TEXT PRIMARY KEY, + revision TEXT, + manifest_key TEXT, + persisted_at TEXT, + version INTEGER NOT NULL DEFAULT 0, + lease_token TEXT, + lease_base_revision TEXT, + lease_version INTEGER, + lease_expires_at INTEGER + )`).run() + await database.prepare(`INSERT OR IGNORE INTO wp_codebox_state (site_id, version) VALUES (?, 0)`).bind(SITE_ID).run() + })() + schemaReady.set(key, pending) + pending.catch(() => schemaReady.delete(key)) + return pending +} diff --git a/packages/runtime-cloudflare/src/published-reader.ts b/packages/runtime-cloudflare/src/published-reader.ts new file mode 100644 index 000000000..0e95f26d5 --- /dev/null +++ b/packages/runtime-cloudflare/src/published-reader.ts @@ -0,0 +1,77 @@ +export const PUBLISHED_REVISION_SCHEMA = "wp-codebox/published-revision/v1" as const +export const PUBLISHED_PAGE_SCHEMA = "wp-codebox/wordpress-page/v2" as const +export const R2_PUBLISHED_CURRENT_KEY = "sites/default/publications/current.json" +export const R2_PUBLISHED_REVISION_PREFIX = "sites/default/publications/revisions" +export const MAX_PUBLISHED_ROUTES = 1_000 +export const MAX_PUBLISHED_REVISION_BYTES = 512 * 1024 +export const MAX_PUBLISHED_PAGE_BYTES = 8 * 1024 * 1024 + +export interface PublishedRoute { + route: string + objectKey: string +} + +export interface PublishedRevision { + schema: typeof PUBLISHED_REVISION_SCHEMA + revision: string + canonicalRevision: string + publishedAt: string + routes: PublishedRoute[] +} + +export function canonicalPublicRoute(input: Request | URL | string): string { + const url = input instanceof Request ? new URL(input.url) : input instanceof URL ? new URL(input) : new URL(input, "https://wp-codebox-runtime.invalid") + url.searchParams.sort() + return `${url.pathname}${url.search}` +} + +export async function publishedPageObjectKey(canonicalRevision: string, route: string): Promise { + if (!isRevision(canonicalRevision) || !isCanonicalRoute(route)) throw new Error("Published page identity is invalid.") + const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(route)) + const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") + return `sites/default/pages/${canonicalRevision}/${hash}.json` +} + +export function publishedRevisionObjectKey(revision: string): string { + if (!isRevision(revision)) throw new Error("Published revision identity is invalid.") + return `${R2_PUBLISHED_REVISION_PREFIX}/${revision}.json` +} + +export function validatePublishedRevision(value: unknown): PublishedRevision { + if (!value || typeof value !== "object") throw new Error("Published revision is invalid.") + const revision = value as Partial + if (revision.schema !== PUBLISHED_REVISION_SCHEMA || !isRevision(revision.revision) || !isRevision(revision.canonicalRevision) + || typeof revision.publishedAt !== "string" || !Number.isFinite(Date.parse(revision.publishedAt)) || !Array.isArray(revision.routes) + || revision.routes.length === 0 || revision.routes.length > MAX_PUBLISHED_ROUTES) throw new Error("Published revision is invalid.") + let previous = "" + for (const route of revision.routes) { + if (!route || typeof route !== "object" || !isCanonicalRoute(route.route) || route.route <= previous + || route.objectKey !== `sites/default/pages/${revision.canonicalRevision}/${route.objectKey.split("/").at(-1)}` + || !/^sites\/default\/pages\/[a-f0-9-]{36}\/[a-f0-9]{64}\.json$/.test(route.objectKey)) throw new Error("Published revision route is invalid.") + previous = route.route + } + return revision as PublishedRevision +} + +export function normalizePublishedRoutes(value: unknown): string[] { + if (!Array.isArray(value) || value.length === 0 || value.length > MAX_PUBLISHED_ROUTES) throw new Error("Publication requires a bounded routes array.") + const routes = value.map((route) => { + if (typeof route !== "string" || !route.startsWith("/")) throw new Error("Publication route is invalid.") + return canonicalPublicRoute(route) + }).sort() + if (routes.some((route, index) => index > 0 && route === routes[index - 1])) throw new Error("Publication routes must be unique.") + return routes +} + +function isCanonicalRoute(route: string): boolean { + if (!route.startsWith("/") || route.includes("#") || route.includes("\\") || route.length > 2_048) return false + try { + return canonicalPublicRoute(route) === route + } catch { + return false + } +} + +function isRevision(revision: unknown): revision is string { + return typeof revision === "string" && /^[a-f0-9-]{36}$/.test(revision) +} diff --git a/packages/runtime-cloudflare/src/request-routing.ts b/packages/runtime-cloudflare/src/request-routing.ts index bcb566805..0c5ca2310 100644 --- a/packages/runtime-cloudflare/src/request-routing.ts +++ b/packages/runtime-cloudflare/src/request-routing.ts @@ -5,6 +5,7 @@ export type WorkerRequestRoute = | { kind: "r2-mutate" } | { kind: "operator-reset" } | { kind: "operator-restore" } + | { kind: "operator-publish" } | { kind: "probe"; phase: string } export function routeWorkerRequest(request: Request): WorkerRequestRoute { @@ -15,5 +16,6 @@ export function routeWorkerRequest(request: Request): WorkerRequestRoute { if (phase === "r2-mutate") return { kind: "r2-mutate" } if (phase === "operator-reset") return { kind: "operator-reset" } if (phase === "operator-restore") return { kind: "operator-restore" } + if (phase === "operator-publish") return { kind: "operator-publish" } return { kind: "probe", phase } } diff --git a/packages/runtime-cloudflare/src/revision-coordinator.ts b/packages/runtime-cloudflare/src/revision-coordinator.ts new file mode 100644 index 000000000..0ddd74ebf --- /dev/null +++ b/packages/runtime-cloudflare/src/revision-coordinator.ts @@ -0,0 +1,34 @@ +export interface MarkdownPointer { + revision: string + manifestKey: string + persistedAt: string +} + +export interface RevisionLease { + token: string + pointer: MarkdownPointer | null + version: number + expiresAt: number +} + +export interface RevisionState { + schema: "wp-codebox/cloudflare-wordpress-state/v2" + store: "durable-object" | "d1" + pointer: MarkdownPointer | null + version: number +} + +export interface RevisionCoordinator { + state(): Promise + acquire(): Promise + release(lease: RevisionLease): Promise + abort(lease: RevisionLease): Promise + commit(lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> + reset(): Promise +} + +export class RevisionConflict extends Error { + constructor(message: string, readonly retryAt?: number) { + super(message) + } +} diff --git a/packages/runtime-cloudflare/src/state-coordinator.ts b/packages/runtime-cloudflare/src/state-coordinator.ts index 528557a41..3e75af0d8 100644 --- a/packages/runtime-cloudflare/src/state-coordinator.ts +++ b/packages/runtime-cloudflare/src/state-coordinator.ts @@ -1,10 +1,6 @@ -export interface MarkdownPointer { - revision: string - manifestKey: string - persistedAt: string -} +import { RevisionConflict, type MarkdownPointer, type RevisionCoordinator, type RevisionLease, type RevisionState } from "./revision-coordinator.js" -interface Lease { +interface StoredLease { token: string base: MarkdownPointer | null version: number @@ -15,7 +11,7 @@ interface CoordinatorRecord { initialized: boolean pointer: MarkdownPointer | null version: number - lease?: Lease + lease?: StoredLease } interface CoordinatorEnv { @@ -25,10 +21,53 @@ interface CoordinatorEnv { const POINTER_KEY = "sites/default/markdown/current.json" const STORAGE_KEY = "wordpress-state-coordinator" -// A cold PHP-WASM WordPress boot has measured around 53 seconds; this remains bounded -// while allowing one lease to cover a cold boot plus request-boundary persistence. const LEASE_MS = 90_000 +export class DurableObjectRevisionCoordinator implements RevisionCoordinator { + constructor(private readonly stub: DurableObjectStub) {} + + state(): Promise { + return this.call("state") + } + + acquire(): Promise { + return this.call("begin", {}) + } + + async release(lease: RevisionLease): Promise { + await this.call("release", { token: lease.token }) + } + + async abort(lease: RevisionLease): Promise { + await this.call("abort", { token: lease.token }) + } + + commit(lease: RevisionLease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> { + return this.call("commit", { token: lease.token, baseRevision: lease.pointer?.revision ?? null, version: lease.version, pointer }) + } + + async reset(): Promise { + await this.call("reset", {}) + } + + private async call(action: string, body?: Record): Promise { + const url = new URL("https://wp-codebox-coordinator.invalid/") + url.searchParams.set("__wp_codebox_coordinator", action) + const response = await this.stub.fetch(new Request(url, body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : undefined)) + if (!response.ok) { + let payload: { message?: string; retryAt?: number } = {} + try { + payload = await response.json<{ message?: string; retryAt?: number }>() + } catch { + // The response status remains sufficient when an adapter cannot return JSON. + } + if (response.status === 409) throw new RevisionConflict(payload.message ?? "Durable Object coordination conflict.", payload.retryAt) + throw new Error(payload.message ?? `Durable Object coordination failed with ${response.status}.`) + } + return response.json() + } +} + export class WordPressStateCoordinator implements DurableObject { private tail: Promise = Promise.resolve() @@ -39,7 +78,7 @@ export class WordPressStateCoordinator implements DurableObject { fetch(request: Request): Promise { const response = this.tail.then(() => this.handle(request)).catch((error: unknown) => { - if (error instanceof CoordinatorConflict) { + if (error instanceof RevisionConflict) { const headers = new Headers() if (error.retryAt) headers.set("retry-after", String(Math.max(1, Math.ceil((error.retryAt - Date.now()) / 1000)))) return Response.json({ schema: "wp-codebox/cloudflare-coordinator-conflict/v1", message: error.message, retryAt: error.retryAt }, { status: 409, headers }) @@ -63,18 +102,16 @@ export class WordPressStateCoordinator implements DurableObject { return new Response("Unknown coordinator action.", { status: 404 }) } - private async current(): Promise<{ schema: string; durableObjectId: string; pointer: MarkdownPointer | null; version: number }> { + private async current(): Promise { const record = await this.record() - return { schema: "wp-codebox/cloudflare-wordpress-state/v1", durableObjectId: this.state.id.toString(), pointer: record.pointer, version: record.version } + return { schema: "wp-codebox/cloudflare-wordpress-state/v2", store: "durable-object", pointer: record.pointer, version: record.version } } - private async begin(): Promise<{ token: string; pointer: MarkdownPointer | null; version: number; expiresAt: number }> { + private async begin(): Promise { const record = await this.record() - if (record.lease && record.lease.expiresAt > Date.now()) { - throw new CoordinatorConflict("A canonical WordPress lease is active.", record.lease.expiresAt) - } + if (record.lease && record.lease.expiresAt > Date.now()) throw new RevisionConflict("A canonical WordPress lease is active.", record.lease.expiresAt) if (record.lease) delete record.lease - const lease: Lease = { token: crypto.randomUUID(), base: record.pointer, version: record.version, expiresAt: Date.now() + (this.env.COORDINATOR_LEASE_MS ?? LEASE_MS) } + const lease: StoredLease = { token: crypto.randomUUID(), base: record.pointer, version: record.version, expiresAt: Date.now() + (this.env.COORDINATOR_LEASE_MS ?? LEASE_MS) } record.lease = lease await this.save(record) return { token: lease.token, pointer: lease.base, version: lease.version, expiresAt: lease.expiresAt } @@ -101,10 +138,10 @@ export class WordPressStateCoordinator implements DurableObject { const lease = this.requireLease(record, body) const pointer = body.pointer as MarkdownPointer if (!pointer || typeof pointer.revision !== "string" || typeof pointer.manifestKey !== "string" || typeof pointer.persistedAt !== "string") { - throw new CoordinatorConflict("A complete canonical pointer is required for promotion.") + throw new RevisionConflict("A complete canonical pointer is required for promotion.") } if (body.baseRevision !== (lease.base?.revision ?? null) || body.version !== lease.version || record.version !== lease.version || record.pointer?.revision !== lease.base?.revision) { - throw new CoordinatorConflict("The canonical pointer changed before promotion.") + throw new RevisionConflict("The canonical pointer changed before promotion.") } await this.env.WORDPRESS_STATE_BUCKET.put(POINTER_KEY, JSON.stringify(pointer), { httpMetadata: { contentType: "application/json" } }) record.pointer = pointer @@ -114,10 +151,10 @@ export class WordPressStateCoordinator implements DurableObject { return { pointer, version: record.version } } - private requireLease(record: CoordinatorRecord, body: Record): Lease { + private requireLease(record: CoordinatorRecord, body: Record): StoredLease { const lease = record.lease - if (!lease || lease.expiresAt <= Date.now()) throw new CoordinatorConflict("The canonical WordPress lease has expired.") - if (body.token !== lease.token) throw new CoordinatorConflict("The canonical WordPress lease token is invalid.") + if (!lease || lease.expiresAt <= Date.now()) throw new RevisionConflict("The canonical WordPress lease has expired.") + if (body.token !== lease.token) throw new RevisionConflict("The canonical WordPress lease token is invalid.") return lease } @@ -140,9 +177,3 @@ export class WordPressStateCoordinator implements DurableObject { return this.state.storage.put(STORAGE_KEY, record) } } - -class CoordinatorConflict extends Error { - constructor(message: string, readonly retryAt?: number) { - super(message) - } -} diff --git a/packages/runtime-cloudflare/src/worker-d1.ts b/packages/runtime-cloudflare/src/worker-d1.ts new file mode 100644 index 000000000..1ab778a0a --- /dev/null +++ b/packages/runtime-cloudflare/src/worker-d1.ts @@ -0,0 +1,10 @@ +import { D1RevisionCoordinator } from "./d1-revision-coordinator.js" +import { createCloudflareRuntime, type RuntimeEnv } from "./worker.js" + +interface D1RuntimeEnv extends RuntimeEnv { + WORDPRESS_STATE_DATABASE: D1Database +} + +export default createCloudflareRuntime((env) => ( + new D1RevisionCoordinator(env.WORDPRESS_STATE_DATABASE) +)) diff --git a/packages/runtime-cloudflare/src/worker-do.ts b/packages/runtime-cloudflare/src/worker-do.ts new file mode 100644 index 000000000..bdc5c0bda --- /dev/null +++ b/packages/runtime-cloudflare/src/worker-do.ts @@ -0,0 +1,12 @@ +import { DurableObjectRevisionCoordinator, WordPressStateCoordinator } from "./state-coordinator.js" +import { createCloudflareRuntime, type RuntimeEnv } from "./worker.js" + +interface DurableObjectRuntimeEnv extends RuntimeEnv { + WORDPRESS_STATE: DurableObjectNamespace +} + +export { WordPressStateCoordinator } + +export default createCloudflareRuntime((env) => ( + new DurableObjectRevisionCoordinator(env.WORDPRESS_STATE.getByName("default")) +)) diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index ff267bddb..a4581849e 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -7,6 +7,8 @@ import { dependenciesTotalSize, init } from "../../../node_modules/@php-wasm/web import phpWasmModule from "../../../node_modules/@php-wasm/web-8-5/asyncify/8_5_8/php_8_5.wasm" import { CLOUDFLARE_RUNTIME_HEALTH_MARKER, CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, cloudflareRuntimeHealthResponse } from "./health-envelope.js" import { leaseRetryDelayMs } from "./lease-retry.js" +import { canonicalPublicRoute, MAX_PUBLISHED_PAGE_BYTES, MAX_PUBLISHED_REVISION_BYTES, normalizePublishedRoutes, PUBLISHED_PAGE_SCHEMA, PUBLISHED_REVISION_SCHEMA, publishedPageObjectKey, publishedRevisionObjectKey, R2_PUBLISHED_CURRENT_KEY, validatePublishedRevision, type PublishedRevision } from "./published-reader.js" +import { RevisionConflict, type MarkdownPointer, type RevisionCoordinator, type RevisionLease } from "./revision-coordinator.js" import { routeWorkerRequest } from "./request-routing.js" import { toFetchResponse, toPHPRequest } from "./request-translation.js" import { R2_UPLOAD_OBJECT_PREFIX, validateUploadManifestFiles, validateUploadMetadata } from "./upload-persistence.js" @@ -16,8 +18,6 @@ import { materializeWordPressRuntimeArtifact, type WordPressRuntimeArtifactManif import { validateWordPressStaticArtifactManifest, type WordPressStaticArtifactManifest } from "./wordpress-static-artifact.js" import { readRuntimeArchiveArtifact, type RuntimeArchiveArtifactManifest } from "./runtime-archive-artifact.js" import { isCanonicalWpContentPath, MAX_WP_CONTENT_FILES, MAX_WP_CONTENT_FILE_BYTES, MAX_WP_CONTENT_TOTAL_BYTES, R2_WP_CONTENT_OBJECT_PREFIX, validateWpContentDeletedPaths, validateWpContentManifestFiles, validateWpContentMetadata } from "./wp-content-persistence.js" -import type { MarkdownPointer } from "./state-coordinator.js" -export { WordPressStateCoordinator } from "./state-coordinator.js" import markdownDatabaseIntegrationRuntime from "../assets/markdown-database-integration-runtime.zip" import canonicalMarkdownSeed from "../assets/markdown-database-integration-canonical-seed.zip" import canonicalMarkdownSeedManifest from "../assets/markdown-database-integration-canonical-seed.json" with { type: "json" } @@ -51,8 +51,7 @@ const MARKDOWN_RESOLVED_INDEX_PATH = "/tmp/markdown-index-8133b4cf3c66.sqlite" const MARKDOWN_CHANGES_PATH = "/tmp/wp-codebox-canonical-changes.json" const R2_MARKDOWN_REVISION_PREFIX = "sites/default/markdown/revisions" const R2_MARKDOWN_OBJECT_PREFIX = "sites/default/markdown/objects" -const R2_WORDPRESS_PAGE_PREFIX = "sites/default/pages" -const WORDPRESS_PAGE_CACHE_SCHEMA = "v2" +const WORDPRESS_PAGE_CACHE_SCHEMA = "v3" const PUBLIC_WP_CONTENT_EXTENSION = /\.(?:css|js|mjs|json|txt|xml|woff2?|ttf|otf|eot|svg|png|jpe?g|gif|webp|avif|ico)$/i const MAX_CRON_EVENTS_PER_INVOCATION = 5 const MAX_CRON_INVOCATION_MS = 25_000 @@ -122,40 +121,44 @@ foreach ($ready as $timestamp => $hooks) { } } echo json_encode(['executed' => false], JSON_THROW_ON_ERROR);` -interface Env { - WORDPRESS_STATE: DurableObjectNamespace +export interface RuntimeEnv { WORDPRESS_STATE_BUCKET: R2Bucket WORDPRESS_ADMIN_PASSWORD?: string WORDPRESS_AUTH_SECRET?: string WORDPRESS_OPERATOR_TOKEN?: string } -export default { - async fetch(request: Request, env: Env): Promise { - if (new URL(request.url).pathname === "/wp-cron.php") return new Response("WordPress cron is managed by the Cloudflare scheduled handler.", { status: 404 }) - const coordinator = env.WORDPRESS_STATE.getByName("default") - const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator) - if (wpContentResponse) return wpContentResponse - const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET) - if (staticResponse) return staticResponse - const route = routeWorkerRequest(request) - const uploadResponse = await serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator) - if (uploadResponse) return uploadResponse - if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator) - if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator) - if (route.kind === "probe") { - return runBootProbe(route.phase, env.WORDPRESS_STATE_BUCKET) - } - if (route.kind === "r2-state") { - if (request.method !== "GET") return new Response("WordPress state read requires GET.", { status: 405 }) - return coordinator.fetch(new Request(coordinatorUrl(request.url, "state"))) - } - return runCoordinatedWordPressRequest(request, env, coordinator, route.kind) - }, - async scheduled(controller: ScheduledController, env: Env): Promise { - const evidence = await runScheduledWordPressCron(env, controller.scheduledTime) - console.log(JSON.stringify(evidence)) - }, +export function createCloudflareRuntime(resolveCoordinator: (env: Env) => RevisionCoordinator) { + return { + async fetch(request: Request, env: Env): Promise { + if (new URL(request.url).pathname === "/wp-cron.php") return new Response("WordPress cron is managed by the Cloudflare scheduled handler.", { status: 404 }) + const publishedResponse = await servePublishedWordPressPage(request, env.WORDPRESS_STATE_BUCKET) + if (publishedResponse) return publishedResponse + const coordinator = resolveCoordinator(env) + const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator) + if (wpContentResponse) return wpContentResponse + const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET) + if (staticResponse) return staticResponse + const route = routeWorkerRequest(request) + const uploadResponse = await serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator) + if (uploadResponse) return uploadResponse + if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator) + if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator) + if (route.kind === "operator-publish") return publishCanonicalWordPressPages(request, env, coordinator) + if (route.kind === "probe") { + return runBootProbe(route.phase, env.WORDPRESS_STATE_BUCKET) + } + if (route.kind === "r2-state") { + if (request.method !== "GET") return new Response("WordPress state read requires GET.", { status: 405 }) + return Response.json(await coordinator.state()) + } + return runCoordinatedWordPressRequest(request, env, coordinator, route.kind) + }, + async scheduled(controller: ScheduledController, env: Env): Promise { + const evidence = await runScheduledWordPressCron(env, resolveCoordinator(env), controller.scheduledTime) + console.log(JSON.stringify(evidence)) + }, + } } interface MarkdownManifestFile { @@ -177,12 +180,10 @@ interface RuntimeFile { bytes: Uint8Array } -interface CoordinatorState { - pointer: MarkdownPointer | null -} - interface WordPressPageSnapshot { - schema: "wp-codebox/wordpress-page/v1" + schema: typeof PUBLISHED_PAGE_SCHEMA + canonicalRevision: string + route: string status: number statusText: string headers: Array<[string, string]> @@ -196,12 +197,7 @@ interface CanonicalSeedManifest { files: Array<{ path: string; sha256: string; size: number }> } -interface Lease { - token: string - pointer: MarkdownPointer | null - version: number - expiresAt: number -} +type Lease = RevisionLease interface Runtime { php: PHP @@ -222,18 +218,18 @@ interface CronInvocationEvidence { let cachedRuntime: { baseRevision: string; promise: Promise } | undefined const LEASE_ACQUISITION_TIMEOUT_MS = 100_000 -async function resetCanonicalWordPress(request: Request, env: Env, coordinator: DurableObjectStub): Promise { +async function resetCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { if (request.method !== "POST") return new Response("Canonical reset requires POST.", { status: 405 }) const authorization = request.headers.get("authorization") if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { return new Response("Canonical reset authorization failed.", { status: 401 }) } - const response = await coordinator.fetch(new Request(coordinatorUrl(request.url, "reset"), { method: "POST", headers: { "content-type": "application/json" }, body: "{}" })) - if (response.ok) await discardCachedRuntime() - return response + await coordinator.reset() + await discardCachedRuntime() + return Response.json({ reset: true }) } -async function restoreCanonicalWordPress(request: Request, env: Env, coordinator: DurableObjectStub): Promise { +async function restoreCanonicalWordPress(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { if (request.method !== "POST") return new Response("Canonical restore requires POST.", { status: 405 }) const authorization = request.headers.get("authorization") if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { @@ -283,10 +279,107 @@ async function secretsMatch(left: string, right: string): Promise { return difference === 0 } -async function runCoordinatedWordPressRequest(request: Request, env: Env, coordinator: DurableObjectStub, route: "wordpress" | "health" | "r2-mutate"): Promise { +async function servePublishedWordPressPage(request: Request, bucket: R2Bucket): Promise { + if (!isCacheableWordPressPageRequest(request) || new URL(request.url).searchParams.has("phase")) return null + if (["/wp-content/", "/wp-includes/"].some((prefix) => new URL(request.url).pathname.startsWith(prefix))) return null + const route = canonicalPublicRoute(request) + const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default + const cacheRequest = publishedPageCacheRequest(request) + const cached = cache ? await cache.match(cacheRequest) : null + if (cached) return publishedPageResponse(cached, request.method === "HEAD", "edge") + + const currentObject = await bucket.get(R2_PUBLISHED_CURRENT_KEY) + if (!currentObject) return null + if (currentObject.size > MAX_PUBLISHED_REVISION_BYTES) throw new Error("Published revision exceeds its size budget.") + const publication = validatePublishedRevision(JSON.parse(await currentObject.text())) + const publishedRoute = publication.routes.find((candidate) => candidate.route === route) + if (!publishedRoute) return null + const snapshotObject = await bucket.get(publishedRoute.objectKey) + if (!snapshotObject) throw new Error(`Published page artifact is unavailable: ${publishedRoute.objectKey}.`) + if (snapshotObject.size > MAX_PUBLISHED_PAGE_BYTES) throw new Error(`Published page artifact exceeds its size budget: ${publishedRoute.objectKey}.`) + const snapshot = JSON.parse(await snapshotObject.text()) as WordPressPageSnapshot + validateWordPressPageSnapshot(snapshot, publication.canonicalRevision, route) + const response = new Response(snapshot.body, { status: snapshot.status, statusText: snapshot.statusText, headers: snapshot.headers }) + const published = publishedPageResponse(response, request.method === "HEAD", "r2", publication.revision) + if (cache && request.method === "GET") { + try { + await cache.put(cacheRequest, published.clone()) + } catch { + // The immutable R2 publication remains authoritative when edge caching is unavailable. + } + } + return published +} + +async function publishCanonicalWordPressPages(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator): Promise { + if (request.method !== "POST") return new Response("Canonical publication requires POST.", { status: 405 }) + const authorization = request.headers.get("authorization") + if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) { + return new Response("Canonical publication authorization failed.", { status: 401 }) + } + let routes: string[] + try { + const body = await request.json<{ routes?: unknown }>() + routes = normalizePublishedRoutes(body.routes) + } catch (error) { + return new Response(error instanceof Error ? error.message : "Canonical publication body is invalid.", { status: 400 }) + } + const state = await coordinator.state() + if (!state.pointer) return new Response("Canonical publication requires initialized state.", { status: 409 }) + let publishedRoutes: Array<{ route: string; objectKey: string }> + try { + publishedRoutes = await Promise.all(routes.map(async (route) => { + const objectKey = await publishedPageObjectKey(state.pointer!.revision, route) + const object = await env.WORDPRESS_STATE_BUCKET.get(objectKey) + if (!object) throw new Error(`Canonical publication route has not been rendered: ${route}.`) + if (object.size > MAX_PUBLISHED_PAGE_BYTES) throw new Error(`Canonical publication route exceeds its size budget: ${route}.`) + const snapshot = JSON.parse(await object.text()) as WordPressPageSnapshot + validateWordPressPageSnapshot(snapshot, state.pointer!.revision, route) + return { route, objectKey } + })) + } catch (error) { + return new Response(error instanceof Error ? error.message : "Canonical publication artifacts are invalid.", { status: 409 }) + } + const publication: PublishedRevision = { + schema: PUBLISHED_REVISION_SCHEMA, + revision: crypto.randomUUID(), + canonicalRevision: state.pointer.revision, + publishedAt: new Date().toISOString(), + routes: publishedRoutes, + } + const serialized = JSON.stringify(publication) + if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_REVISION_BYTES) return new Response("Canonical publication exceeds its size budget.", { status: 413 }) + await putImmutableJson(env.WORDPRESS_STATE_BUCKET, publishedRevisionObjectKey(publication.revision), serialized) + await env.WORDPRESS_STATE_BUCKET.put(R2_PUBLISHED_CURRENT_KEY, serialized, { httpMetadata: { contentType: "application/json" } }) + const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default + if (cache) await Promise.all(routes.map((route) => cache.delete(publishedPageCacheRequest(new Request(new URL(route, request.url)))))) + return Response.json(publication) +} + +function publishedPageCacheRequest(request: Request): Request { + return new Request(`https://wp-codebox-publication.invalid${canonicalPublicRoute(request)}`, { method: "GET" }) +} + +function publishedPageResponse(response: Response, head: boolean, source: "edge" | "r2", revision?: string): Response { + const headers = new Headers(response.headers) + headers.set("cache-control", "public, max-age=60, s-maxage=60") + headers.set("x-wp-codebox-page-cache", "hit") + headers.set("x-wp-codebox-page-cache-source", `publication-${source}`) + if (revision) headers.set("x-wp-codebox-publication-revision", revision) + return new Response(head ? null : response.body, { status: response.status, statusText: response.statusText, headers }) +} + +function validateWordPressPageSnapshot(snapshot: WordPressPageSnapshot, canonicalRevision: string, route: string): void { + if (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.some((header) => !Array.isArray(header) || header.length !== 2 || header.some((value) => typeof value !== "string")) + || typeof snapshot.body !== "string") throw new Error("Published page artifact is invalid.") +} + +async function runCoordinatedWordPressRequest(request: Request, env: RuntimeEnv, coordinator: RevisionCoordinator, route: "wordpress" | "health" | "r2-mutate"): Promise { if (route === "r2-mutate" && request.method !== "POST") return new Response("WordPress state mutation requires POST.", { status: 405 }) if (route === "wordpress" && isCacheableWordPressPageRequest(request)) { - const state = await coordinatorCall(coordinator, request.url, "state") + const state = await coordinator.state() if (state.pointer) { const cachedPage = await matchWordPressPageCache(request, state.pointer, env.WORDPRESS_STATE_BUCKET) if (cachedPage) return cachedPage @@ -359,7 +452,8 @@ async function matchWordPressPageCache(request: Request, pointer: MarkdownPointe const object = await bucket.get(await wordPressPageSnapshotKey(request, pointer)) if (!object) return null const snapshot = JSON.parse(await object.text()) as WordPressPageSnapshot - if (snapshot.schema !== "wp-codebox/wordpress-page/v1" || snapshot.status !== 200 || !Array.isArray(snapshot.headers) || typeof snapshot.body !== "string") return null + validateWordPressPageSnapshot(snapshot, pointer.revision, canonicalPublicRoute(request)) + if (snapshot.status !== 200) return null const response = new Response(snapshot.body, { status: snapshot.status, statusText: snapshot.statusText, headers: snapshot.headers }) if (cache) await cache.put(wordPressPageCacheKey(request, pointer), response.clone()) return pageCacheResponse(response, request.method === "HEAD", "hit", "r2") @@ -375,15 +469,19 @@ async function cacheWordPressPage(request: Request, pointer: MarkdownPointer, re const cacheable = pageCacheResponse(response, false, "miss", "render") try { const snapshot: WordPressPageSnapshot = { - schema: "wp-codebox/wordpress-page/v1", + schema: PUBLISHED_PAGE_SCHEMA, + canonicalRevision: pointer.revision, + route: canonicalPublicRoute(request), status: cacheable.status, statusText: cacheable.statusText, headers: Array.from(cacheable.headers.entries()), body: await cacheable.clone().text(), } + const serialized = JSON.stringify(snapshot) + if (new TextEncoder().encode(serialized).byteLength > MAX_PUBLISHED_PAGE_BYTES) return cacheable await Promise.all([ cache ? cache.put(wordPressPageCacheKey(request, pointer), cacheable.clone()) : Promise.resolve(), - bucket.put(await wordPressPageSnapshotKey(request, pointer), JSON.stringify(snapshot), { httpMetadata: { contentType: "application/json" } }), + putImmutableJson(bucket, await wordPressPageSnapshotKey(request, pointer), serialized), ]) } catch { // Page caching is an optimization; canonical rendering remains authoritative. @@ -391,6 +489,13 @@ async function cacheWordPressPage(request: Request, pointer: MarkdownPointer, re return cacheable } +async function putImmutableJson(bucket: R2Bucket, key: string, serialized: string): Promise { + const created = await bucket.put(key, serialized, { onlyIf: { etagDoesNotMatch: "*" }, httpMetadata: { contentType: "application/json" } }) + if (created) return + const existing = await bucket.get(key) + if (!existing || await existing.text() !== serialized) throw new Error(`Immutable R2 object conflicts with existing content: ${key}.`) +} + function wordPressPageCacheKey(request: Request, pointer: MarkdownPointer): Request { const url = new URL(request.url) url.searchParams.set("__wp_codebox_revision", pointer.revision) @@ -399,9 +504,7 @@ function wordPressPageCacheKey(request: Request, pointer: MarkdownPointer): Requ } async function wordPressPageSnapshotKey(request: Request, pointer: MarkdownPointer): Promise { - const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(new URL(request.url).toString())) - const hash = Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") - return `${R2_WORDPRESS_PAGE_PREFIX}/${pointer.revision}/${hash}.json` + return publishedPageObjectKey(pointer.revision, canonicalPublicRoute(request)) } function pageCacheResponse(response: Response, head: boolean, status: "hit" | "miss", source: "edge" | "r2" | "render"): Response { @@ -419,56 +522,42 @@ function isMutation(request: Request, route: "wordpress" | "health" | "r2-mutate return url.pathname.startsWith("/wp-admin/") && !!url.searchParams.get("action") && url.searchParams.get("action") !== "-1" } -function coordinatorUrl(requestUrl: string, action: string): string { - const url = new URL(requestUrl) - url.searchParams.set("__wp_codebox_coordinator", action) - return url.toString() -} - -async function coordinatorCall(coordinator: DurableObjectStub, requestUrl: string, action: string, body?: Record): Promise { - const response = await coordinator.fetch(new Request(coordinatorUrl(requestUrl, action), body ? { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(body) } : undefined)) - if (!response.ok) { - const detail = await response.text() - const retryAfter = response.headers.get("retry-after") - throw new CoordinatorRequestError(response.status, detail, retryAfter ? Number(retryAfter) : undefined) - } - return response.json() -} - -async function acquireLease(coordinator: DurableObjectStub, requestUrl: string): Promise { +async function acquireLease(coordinator: RevisionCoordinator, _requestUrl: string): Promise { const deadline = Date.now() + LEASE_ACQUISITION_TIMEOUT_MS - let lastError: CoordinatorRequestError | undefined + let lastError: RevisionConflict | undefined while (true) { const remainingMs = deadline - Date.now() if (remainingMs <= 0) break try { - return await coordinatorCall(coordinator, requestUrl, "begin", {}) + return await coordinator.acquire() } catch (error) { - if (!(error instanceof CoordinatorRequestError) || error.status !== 409) throw error + if (!(error instanceof RevisionConflict)) throw error lastError = error - await new Promise((resolve) => setTimeout(resolve, leaseRetryDelayMs(error.retryAfter, deadline - Date.now()))) + const retryAfter = error.retryAt ? Math.max(1, Math.ceil((error.retryAt - Date.now()) / 1000)) : undefined + await new Promise((resolve) => setTimeout(resolve, leaseRetryDelayMs(retryAfter, deadline - Date.now()))) } } throw new Error(`Timed out waiting for the canonical WordPress lease${lastError ? `: ${lastError.message}` : "."}`) } -function releaseLease(coordinator: DurableObjectStub, requestUrl: string, lease: Lease): Promise<{ released: true }> { - return coordinatorCall(coordinator, requestUrl, "release", { token: lease.token }) +async function releaseLease(coordinator: RevisionCoordinator, _requestUrl: string, lease: Lease): Promise<{ released: true }> { + await coordinator.release(lease) + return { released: true } } -async function abortLease(coordinator: DurableObjectStub, requestUrl: string, lease: Lease): Promise { +async function abortLease(coordinator: RevisionCoordinator, _requestUrl: string, lease: Lease): Promise { try { - await coordinatorCall(coordinator, requestUrl, "abort", { token: lease.token }) + await coordinator.abort(lease) } catch (error) { - if (!(error instanceof CoordinatorRequestError) || error.status !== 409) throw error + if (!(error instanceof RevisionConflict)) throw error } } -function commitLease(coordinator: DurableObjectStub, requestUrl: string, lease: Lease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> { - return coordinatorCall(coordinator, requestUrl, "commit", { token: lease.token, baseRevision: lease.pointer?.revision ?? null, version: lease.version, pointer }) +function commitLease(coordinator: RevisionCoordinator, _requestUrl: string, lease: Lease, pointer: MarkdownPointer): Promise<{ pointer: MarkdownPointer; version: number }> { + return coordinator.commit(lease, pointer) } -async function getRuntime(env: Env, pointer: MarkdownPointer, origin: string): Promise { +async function getRuntime(env: RuntimeEnv, pointer: MarkdownPointer, origin: string): Promise { if (cachedRuntime && cachedRuntime.baseRevision !== pointer.revision) await discardCachedRuntime() if (!cachedRuntime) { const promise = bootRuntime(env.WORDPRESS_STATE_BUCKET, pointer, origin, await canonicalWordPressAuthConstants(env)) @@ -511,7 +600,7 @@ async function bootRuntime(bucket: R2Bucket, pointer: MarkdownPointer, origin: s return { ...await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, revision.markdown, new Uint8Array(markdownPrimaryBootstrapIndex), origin, authConstants, bucket, true, revision.uploads, revision.wpContent, revision.wpContentDeleted), pointer } } -async function bootstrapCanonicalRuntime(env: Env, coordinator: DurableObjectStub, requestUrl: string, lease: Lease): Promise { +async function bootstrapCanonicalRuntime(env: RuntimeEnv, coordinator: RevisionCoordinator, requestUrl: string, lease: Lease): Promise { if (!env.WORDPRESS_ADMIN_PASSWORD) throw new Error("WORDPRESS_ADMIN_PASSWORD is required to bootstrap a complete canonical WordPress revision.") const origin = new URL(requestUrl).origin const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, await packagedCanonicalMarkdownSeed(), new Uint8Array(markdownPrimaryBootstrapIndex), origin, await canonicalWordPressAuthConstants(env), env.WORDPRESS_STATE_BUCKET, true) @@ -559,7 +648,7 @@ require '/wordpress/wp-load.php'; $GLOBALS['wpdb']->flush_canonical_writes(); echo 'flushed';` } -async function canonicalWordPressAuthConstants(env: Env): Promise> { +async function canonicalWordPressAuthConstants(env: RuntimeEnv): Promise> { return deriveWordPressAuthConstants(env.WORDPRESS_AUTH_SECRET ?? "", "default") } @@ -584,7 +673,7 @@ async function runSyntheticMutation(runtime: Runtime): Promise<{ response: Respo return { response: Response.json({ schema: "wp-codebox/cloudflare-wordpress-mutation/v1", source: "entry-worker-primary-runtime", ...mutation, canonicalFiles: collectRuntimeFiles(runtime.php, MARKDOWN_ROOT).length, markdownDatabaseIntegrationRevision: MARKDOWN_DATABASE_INTEGRATION_REVISION, sqlitePersisted: false }), canonicalChanges: mutation.canonicalChanges } } -async function runScheduledWordPressCron(env: Env, scheduledTime: number): Promise { +async function runScheduledWordPressCron(env: RuntimeEnv, coordinator: RevisionCoordinator, scheduledTime: number): Promise { const started = Date.now() const evidence: CronInvocationEvidence = { schema: "wp-codebox/cloudflare-cron/v1", @@ -594,7 +683,6 @@ async function runScheduledWordPressCron(env: Env, scheduledTime: number): Promi events: [], status: "completed", } - const coordinator = env.WORDPRESS_STATE.getByName("default") const requestUrl = `${SITE_URL}/wp-cron.php?doing_wp_cron=${scheduledTime}` while (evidence.events.length < MAX_CRON_EVENTS_PER_INVOCATION && Date.now() - started < MAX_CRON_INVOCATION_MS) { const lease = await acquireLease(coordinator, requestUrl) @@ -644,12 +732,6 @@ async function health(runtime: Runtime): Promise { return cloudflareRuntimeHealthResponse({ schema: CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, marker: CLOUDFLARE_RUNTIME_HEALTH_MARKER, wordpressVersion: runtime.wordpressVersion, phpVersion, runtime: { backend: "wordpress-playground", environment: "wordpress" }, evidence: { initialization: "completed", execution: "completed", initializationScope: "isolate" } }) } -class CoordinatorRequestError extends Error { - constructor(readonly status: number, message: string, readonly retryAfter?: number) { - super(message) - } -} - interface MarkdownChanges { created: string[] changed: string[] @@ -1335,7 +1417,7 @@ async function readSqliteIntegrationArtifact(bucket: R2Bucket): Promise { return new File([Uint8Array.from(bytes).buffer], "sqlite-database-integration.zip", { type: "application/zip" }) } -async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coordinator: DurableObjectStub): Promise { +async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coordinator: RevisionCoordinator): Promise { if (request.method !== "GET" && request.method !== "HEAD") return null const url = new URL(request.url) if (!url.pathname.startsWith("/wp-content/") || !PUBLIC_WP_CONTENT_EXTENSION.test(url.pathname)) return null @@ -1346,7 +1428,7 @@ async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coord return new Response("Invalid WordPress content path.", { status: 400 }) } if (!isCanonicalWpContentPath(path)) return null - const state = await coordinatorCall(coordinator, request.url, "state") + const state = await coordinator.state() if (!state.pointer) return null const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default const cacheKey = wordPressRevisionCacheKey(request, state.pointer) @@ -1390,7 +1472,7 @@ async function serveWordPressWpContent(request: Request, bucket: R2Bucket, coord return response } -async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordinator: DurableObjectStub): Promise { +async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordinator: RevisionCoordinator): Promise { if (request.method !== "GET" && request.method !== "HEAD") return null const url = new URL(request.url) if (!url.pathname.startsWith("/wp-content/uploads/")) return null @@ -1401,7 +1483,7 @@ async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordina return new Response("Invalid WordPress upload path.", { status: 400 }) } if (!isCanonicalRelativePath(path)) return new Response("Invalid WordPress upload path.", { status: 400 }) - const state = await coordinatorCall(coordinator, request.url, "state") + const state = await coordinator.state() if (!state.pointer) return null const cache = typeof caches === "undefined" ? undefined : (caches as CacheStorage & { default?: Cache }).default const cacheKey = wordPressRevisionCacheKey(request, state.pointer) diff --git a/packages/runtime-cloudflare/wrangler.d1.jsonc b/packages/runtime-cloudflare/wrangler.d1.jsonc new file mode 100644 index 000000000..89014dff2 --- /dev/null +++ b/packages/runtime-cloudflare/wrangler.d1.jsonc @@ -0,0 +1,23 @@ +// ChatGPT Sites profile: D1 coordinates revisions; R2 stores canonical and published artifacts. +{ + "name": "wp-codebox-cloudflare-runtime", + "main": "src/worker-d1.ts", + "compatibility_date": "2026-07-18", + "compatibility_flags": ["nodejs_compat"], + "limits": { "cpu_ms": 300000 }, + "triggers": { "crons": ["* * * * *"] }, + "r2_buckets": [ + { "binding": "WORDPRESS_STATE_BUCKET", "bucket_name": "wp-codebox-runtime-chubes" } + ], + "d1_databases": [ + { + "binding": "WORDPRESS_STATE_DATABASE", + "database_name": "wp-codebox-runtime-state", + "database_id": "00000000-0000-0000-0000-000000000000" + } + ], + "rules": [ + { "type": "CompiledWasm", "globs": ["**/*.wasm"], "fallthrough": false }, + { "type": "Data", "globs": ["**/*.sqlite", "**/*-runtime.zip", "**/*-canonical-seed.zip"], "fallthrough": false } + ] +} diff --git a/packages/runtime-cloudflare/wrangler.jsonc b/packages/runtime-cloudflare/wrangler.jsonc index ba43cf55c..a207e696b 100644 --- a/packages/runtime-cloudflare/wrangler.jsonc +++ b/packages/runtime-cloudflare/wrangler.jsonc @@ -1,7 +1,7 @@ // Configure WORDPRESS_AUTH_SECRET with `wrangler secret put`; it is intentionally absent from this file. { "name": "wp-codebox-cloudflare-runtime", - "main": "src/worker.ts", + "main": "src/worker-do.ts", "compatibility_date": "2026-07-18", "compatibility_flags": ["nodejs_compat"], "limits": { "cpu_ms": 300000 }, diff --git a/scripts/cloudflare-local-gate.mjs b/scripts/cloudflare-local-gate.mjs index 088af6aa1..227049583 100644 --- a/scripts/cloudflare-local-gate.mjs +++ b/scripts/cloudflare-local-gate.mjs @@ -10,6 +10,9 @@ const port = 8792 const origin = `http://127.0.0.1:${port}` const password = "cloudflare-runtime-test-password" const authSecret = "cloudflare-runtime-test-auth-secret" +const operatorToken = "cloudflare-runtime-test-operator-token" +const coordinator = process.argv.includes("--coordinator=d1") ? "d1" : "durable-object" +const wranglerConfig = coordinator === "d1" ? "packages/runtime-cloudflare/wrangler.d1.jsonc" : "packages/runtime-cloudflare/wrangler.jsonc" const stateDirectory = await mkdtemp(join(tmpdir(), "wp-codebox-cloudflare-gate-")) const cookies = [] let child @@ -22,6 +25,7 @@ try { await assertFullBootProbe() await assertWordPressCronDisabled() await assertConcurrentMutations() + await assertCoordinatorBackend() const coldHome = await timedWordPressPage(origin, "cold explanatory homepage") const warmHome = await timedWordPressPage(origin, "warm explanatory homepage") await assertExplanatoryHomepage(warmHome.body) @@ -36,8 +40,11 @@ try { const media = await createMedia(adminHtml) await assertMediaFile(media, "uploaded media") const post = await createPost(adminHtml) - const frontPage = await assertWordPressPage(`${origin}/${post.slug}/`, "published post") + const frontPage = await assertWordPressPage(new URL(post.route, origin), "published post") assertIncludes(frontPage, post.title, "published post") + await assertAnonymousWordPressPage(new URL(post.route, origin), "post publication candidate") + await assertAnonymousWordPressPage(origin, "homepage publication candidate") + await publishRoutes(["/", post.route]) await assertHealthResponse() await assertLinkedAssets(frontPage, "front-end") await assertLinkedAssets(adminHtml, "admin") @@ -47,7 +54,7 @@ try { await startWorker() const restartedAdmin = await assertAuthenticatedDashboard(new URL("/wp-admin/", origin)) - const restartedPost = await assertWordPressPage(`${origin}/${post.slug}/`, "post after cold restart") + const restartedPost = await assertPublishedWordPressPage(new URL(post.route, origin), "post after publication restart") assertIncludes(restartedPost, post.title, "post after cold restart") await assertMediaFile(media, "media after cold restart") await assertMediaMetadata(media, "media metadata after cold restart") @@ -66,7 +73,7 @@ try { await assertScheduledPost(scheduledPost.id, "publish", 1, false, "scheduled post after duplicate cron trigger") cookies.length = 0 await login() - console.log("Cloudflare local runtime gate passed: canonical full-boot probe, explanatory homepage, complete block styles, revision page cache, login, dashboard, post editor, concurrent canonical mutations, authenticated REST post and media creation, plugin ZIP installation and activation, direct R2 upload serving, frontend/admin/editor assets, cold-restart persistence, and bounded durable scheduled callback execution.") + console.log(`Cloudflare local runtime gate passed with ${coordinator} coordination: canonical full-boot probe, explanatory homepage, complete block styles, coordinator-free R2 publication reads, login, dashboard, post editor, concurrent canonical mutations, authenticated REST post and media creation, plugin ZIP installation and activation, direct R2 upload serving, frontend/admin/editor assets, cold-restart persistence, and bounded durable scheduled callback execution.`) } finally { await stopWorker() await rm(stateDirectory, { recursive: true, force: true }) @@ -82,7 +89,7 @@ async function run(command, args) { async function startWorker() { output = "" - child = spawn("npm", ["exec", "--", "wrangler", "dev", "--test-scheduled", "--config", "packages/runtime-cloudflare/wrangler.jsonc", "--port", String(port), "--persist-to", stateDirectory, "--var", `WORDPRESS_ADMIN_PASSWORD:${password}`, "--var", `WORDPRESS_AUTH_SECRET:${authSecret}`], { + child = spawn("npm", ["exec", "--", "wrangler", "dev", "--test-scheduled", "--config", wranglerConfig, "--port", String(port), "--persist-to", stateDirectory, "--var", `WORDPRESS_ADMIN_PASSWORD:${password}`, "--var", `WORDPRESS_AUTH_SECRET:${authSecret}`, "--var", `WORDPRESS_OPERATOR_TOKEN:${operatorToken}`], { cwd: process.cwd(), // The host PAC resolves these public archive hosts through an unavailable local proxy. env: { ...process.env, NO_PROXY: "wordpress.org,github.com,codeload.github.com", no_proxy: "wordpress.org,github.com,codeload.github.com" }, @@ -138,8 +145,9 @@ async function createPost(adminHtml) { assertNoPhpDiagnostics(body, "REST post creation") if (response.status !== 201) throw new Error(`Expected REST post creation, received ${response.status}: ${body}`) const post = JSON.parse(body) - if (typeof post.slug !== "string" || post.title?.rendered !== title) throw new Error(`Unexpected REST post response: ${body}`) - return { slug: post.slug, title } + if (typeof post.slug !== "string" || typeof post.link !== "string" || post.title?.rendered !== title) throw new Error(`Unexpected REST post response: ${body}`) + const link = new URL(post.link) + return { slug: post.slug, route: `${link.pathname}${link.search}`, title } } async function createMedia(adminHtml) { @@ -396,6 +404,14 @@ async function assertFullBootProbe() { console.log(`Canonical full-boot probe timing: ${Math.round(performance.now() - startedAt)}ms.`) } +async function assertCoordinatorBackend() { + const response = await fetch(`${origin}/?phase=r2-state`) + const payload = await response.json() + if (!response.ok || payload.schema !== "wp-codebox/cloudflare-wordpress-state/v2" || payload.store !== coordinator || !payload.pointer?.revision) { + throw new Error(`Unexpected ${coordinator} coordinator state: status=${response.status} payload=${JSON.stringify(payload)}.`) + } +} + async function assertWordPressPage(target, label) { const response = await request(target) const body = await response.text() @@ -404,6 +420,43 @@ async function assertWordPressPage(target, label) { return body } +async function assertPublishedWordPressPage(target, label) { + const response = await fetch(target) + const body = await response.text() + assertNoPhpDiagnostics(body, label) + if (response.status !== 200 || response.headers.get("x-wp-codebox-page-cache-source") !== "publication-r2" + || !response.headers.get("x-wp-codebox-publication-revision") || !/]/i.test(body)) { + throw new Error(`Expected a coordinator-free R2 ${label}, received ${response.status}/${response.headers.get("x-wp-codebox-page-cache-source")}.`) + } + return body +} + +async function assertAnonymousWordPressPage(target, label) { + const response = await fetch(target) + const body = await response.text() + assertNoPhpDiagnostics(body, label) + if (response.status !== 200 || !response.headers.get("content-type")?.includes("text/html") || !/]/i.test(body)) throw new Error(`Expected an anonymous HTML ${label}, received ${response.status}: ${body}`) + return body +} + +async function publishRoutes(routes) { + const response = await request(`${origin}/?phase=operator-publish`, { + method: "POST", + headers: { authorization: `Bearer ${operatorToken}`, "content-type": "application/json" }, + body: JSON.stringify({ routes }), + }) + const body = await response.text() + let payload + try { + payload = JSON.parse(body) + } catch { + throw new Error(`Canonical publication returned non-JSON: status=${response.status} body=${body}.\n${stripVTControlCharacters(output).split("\n").filter((line) => /error|exception|publication/i.test(line)).slice(-30).join("\n")}`) + } + if (!response.ok || payload.schema !== "wp-codebox/published-revision/v1" || payload.routes?.length !== routes.length) { + throw new Error(`Canonical publication failed: status=${response.status} payload=${JSON.stringify(payload)}.`) + } +} + async function timedWordPressPage(target, label) { const startedAt = performance.now() const response = await request(target) diff --git a/tests/cloudflare-runtime.test.ts b/tests/cloudflare-runtime.test.ts index 85739172d..8d6da0d1e 100644 --- a/tests/cloudflare-runtime.test.ts +++ b/tests/cloudflare-runtime.test.ts @@ -8,6 +8,7 @@ import { decodeZip, encodeZip } from "@php-wasm/stream-compression" import { RUNTIME_COMMAND_RESULT_SCHEMA } from "../packages/runtime-core/src/runtime-contracts.js" import { CLOUDFLARE_RUNTIME_HEALTH_MARKER, CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, cloudflareRuntimeHealthResponse } from "../packages/runtime-cloudflare/src/health-envelope.js" import { leaseRetryDelayMs } from "../packages/runtime-cloudflare/src/lease-retry.js" +import { canonicalPublicRoute, normalizePublishedRoutes, PUBLISHED_REVISION_SCHEMA, publishedPageObjectKey, publishedRevisionObjectKey, R2_PUBLISHED_CURRENT_KEY, validatePublishedRevision } from "../packages/runtime-cloudflare/src/published-reader.js" import { routeWorkerRequest } from "../packages/runtime-cloudflare/src/request-routing.js" import { toFetchResponse, toPHPRequest } from "../packages/runtime-cloudflare/src/request-translation.js" import { WordPressStateCoordinator } from "../packages/runtime-cloudflare/src/state-coordinator.js" @@ -76,9 +77,32 @@ test("Cloudflare routing reserves phases while the phase-less route serves WordP assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=r2-mutate")), { kind: "r2-mutate" }) assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-reset")), { kind: "operator-reset" }) assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-restore")), { kind: "operator-restore" }) + assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-publish")), { kind: "operator-publish" }) assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=seeded-wordpress")), { kind: "probe", phase: "seeded-wordpress" }) }) +test("Cloudflare publication contracts are host-independent, immutable, and bounded", async () => { + const canonicalRevision = "11111111-1111-4111-8111-111111111111" + const publicationRevision = "22222222-2222-4222-8222-222222222222" + assert.equal(canonicalPublicRoute("/news/?z=2&a=1"), "/news/?a=1&z=2") + assert.deepEqual(normalizePublishedRoutes(["/news/?z=2&a=1", "/"]), ["/", "/news/?a=1&z=2"]) + assert.throws(() => normalizePublishedRoutes(["/", "/"]), /unique/) + assert.throws(() => normalizePublishedRoutes(["https://foreign.example/"]), /invalid/) + const objectKey = await publishedPageObjectKey(canonicalRevision, "/") + assert.equal(objectKey, await publishedPageObjectKey(canonicalRevision, canonicalPublicRoute("https://another-host.example/"))) + const publication = validatePublishedRevision({ + schema: PUBLISHED_REVISION_SCHEMA, + revision: publicationRevision, + canonicalRevision, + publishedAt: "2026-07-22T00:00:00.000Z", + routes: [{ route: "/", objectKey }], + }) + assert.equal(publication.routes[0].objectKey, objectKey) + assert.equal(publishedRevisionObjectKey(publicationRevision), `sites/default/publications/revisions/${publicationRevision}.json`) + assert.equal(R2_PUBLISHED_CURRENT_KEY, "sites/default/publications/current.json") + assert.throws(() => validatePublishedRevision({ ...publication, routes: [{ route: "/", objectKey: "sites/default/pages/foreign.json" }] }), /route is invalid/) +}) + test("Cloudflare serves only safe browser assets from the WordPress archive", () => { assert.equal(wordpressStaticArchivePath("/wp-includes/js/jquery/jquery.min.js"), "wordpress/wp-includes/js/jquery/jquery.min.js") assert.equal(wordpressStaticArchivePath("/wp-admin/css/common.min.css"), "wordpress/wp-admin/css/common.min.css") @@ -138,9 +162,24 @@ test("Cloudflare translates Fetch requests and PHP responses without losing brow }) test("Cloudflare runtime declares bounded CPU and scheduled execution", async () => { - const config = JSON.parse((await readFile(new URL("../packages/runtime-cloudflare/wrangler.jsonc", import.meta.url), "utf8")).replace(/^\s*\/\/.*\n/, "")) as { limits?: { cpu_ms?: number }; triggers?: { crons?: string[] } } + const config = JSON.parse((await readFile(new URL("../packages/runtime-cloudflare/wrangler.jsonc", import.meta.url), "utf8")).replace(/^\s*\/\/.*\n/, "")) as { main?: string; limits?: { cpu_ms?: number }; triggers?: { crons?: string[] } } + const d1Config = JSON.parse((await readFile(new URL("../packages/runtime-cloudflare/wrangler.d1.jsonc", import.meta.url), "utf8")).replace(/^\s*\/\/.*\n/, "")) as { + main?: string + limits?: { cpu_ms?: number } + triggers?: { crons?: string[] } + d1_databases?: Array<{ binding?: string; database_name?: string }> + durable_objects?: unknown + migrations?: unknown + } + assert.equal(config.main, "src/worker-do.ts") assert.equal(config.limits?.cpu_ms, 300_000) assert.deepEqual(config.triggers?.crons, ["* * * * *"]) + assert.equal(d1Config.main, "src/worker-d1.ts") + assert.equal(d1Config.limits?.cpu_ms, 300_000) + assert.deepEqual(d1Config.triggers?.crons, ["* * * * *"]) + assert.deepEqual(d1Config.d1_databases?.map(({ binding, database_name }) => ({ binding, database_name })), [{ binding: "WORDPRESS_STATE_DATABASE", database_name: "wp-codebox-runtime-state" }]) + assert.equal(d1Config.durable_objects, undefined) + assert.equal(d1Config.migrations, undefined) }) test("Cloudflare lease contention honors Retry-After without exceeding the acquisition deadline", () => { @@ -242,9 +281,13 @@ test("Cloudflare canonical runtime patches the unique init call with runtime per assert.match(worker, /canonicalBootstrapUrlCode/) assert.match(worker, /canonicalBootstrapFlushCode/) }) -test("Cloudflare keeps PHP-WASM in the entry Worker and uses the Durable Object only for leases", async () => { +test("Cloudflare runtime injects composable coordinators without moving PHP out of the Worker core", async () => { const worker = await readFile(new URL("../packages/runtime-cloudflare/src/worker.ts", import.meta.url), "utf8") const coordinator = await readFile(new URL("../packages/runtime-cloudflare/src/state-coordinator.ts", import.meta.url), "utf8") + const d1Coordinator = await readFile(new URL("../packages/runtime-cloudflare/src/d1-revision-coordinator.ts", import.meta.url), "utf8") + const contract = await readFile(new URL("../packages/runtime-cloudflare/src/revision-coordinator.ts", import.meta.url), "utf8") + const durableObjectEntry = await readFile(new URL("../packages/runtime-cloudflare/src/worker-do.ts", import.meta.url), "utf8") + const d1Entry = await readFile(new URL("../packages/runtime-cloudflare/src/worker-d1.ts", import.meta.url), "utf8") const materializer = worker.slice(worker.indexOf("async function materializeWordPressServerFiles"), worker.indexOf("async function serveWordPressStaticAsset")) const corpus = await readFile(new URL("../packages/runtime-cloudflare/src/wordpress-runtime-corpus.ts", import.meta.url), "utf8") @@ -267,6 +310,16 @@ test("Cloudflare keeps PHP-WASM in the entry Worker and uses the Durable Object assert.match(noPointerBoot, /await commitLease\(coordinator, requestUrl, lease, pointer\)/) assert.doesNotMatch(noPointerBoot, /wordpressInstallSeed|databaseSeed|bootstrap_existing_cache/) assert.doesNotMatch(coordinator, /@php-wasm|PHPRequestHandler|bootWordPressRuntime|new PHP\(/) + assert.doesNotMatch(d1Coordinator, /@php-wasm|PHPRequestHandler|bootWordPressRuntime|new PHP\(|DurableObject/) + assert.doesNotMatch(d1Coordinator, /R2Bucket|WORDPRESS_STATE_BUCKET|MARKDOWN_ROOT|WP_Markdown|wp_posts/) + assert.match(d1Coordinator, /lease_token IS NULL OR lease_expires_at <= \?/) + assert.match(d1Coordinator, /lease_token = \? AND lease_expires_at > \? AND version = \? AND lease_version = \?/) + assert.match(d1Coordinator, /lease_base_revision IS NULL AND \? IS NULL/) + assert.match(contract, /interface RevisionCoordinator/) + assert.match(contract, /acquire\(\): Promise/) + assert.match(durableObjectEntry, /new DurableObjectRevisionCoordinator\(env\.WORDPRESS_STATE\.getByName\("default"\)\)/) + assert.match(d1Entry, /new D1RevisionCoordinator\(env\.WORDPRESS_STATE_DATABASE\)/) + assert.doesNotMatch(d1Entry, /DurableObject|WORDPRESS_STATE\.getByName/) assert.match(coordinator, /token: crypto\.randomUUID\(\)/) assert.match(coordinator, /record\.version\+\+/) assert.match(coordinator, /lease\.expiresAt <= Date\.now\(\)/) @@ -279,6 +332,8 @@ test("Cloudflare keeps PHP-WASM in the entry Worker and uses the Durable Object assert.match(worker, /canonicalWordPressAuthConstants\(env\)/) assert.match(worker, /authConstants/) assert.match(worker, /const wpContentResponse = await serveWordPressWpContent\(request, env\.WORDPRESS_STATE_BUCKET, coordinator\)/) + assert.match(worker, /const publishedResponse = await servePublishedWordPressPage\(request, env\.WORDPRESS_STATE_BUCKET\)/) + assert.ok(worker.indexOf("servePublishedWordPressPage(request, env.WORDPRESS_STATE_BUCKET)") < worker.indexOf("const coordinator = resolveCoordinator(env)")) assert.match(worker, /const staticResponse = await serveWordPressStaticAsset\(request, env\.WORDPRESS_STATE_BUCKET\)/) assert.ok(worker.indexOf("const wpContentResponse = await serveWordPressWpContent(request, env.WORDPRESS_STATE_BUCKET, coordinator)") < worker.indexOf("const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET)")) assert.ok(worker.indexOf("const staticResponse = await serveWordPressStaticAsset(request, env.WORDPRESS_STATE_BUCKET)") < worker.indexOf("const route = routeWorkerRequest(request)")) @@ -295,16 +350,22 @@ test("Cloudflare keeps PHP-WASM in the entry Worker and uses the Durable Object assert.match(worker, /"x-wp-codebox-static": "r2-range"/) assert.match(worker, /cache\.match\(cacheRequest\)/) assert.match(worker, /cache\.put\(cacheRequest, response\.clone\(\)\)/) - assert.match(worker, /coordinatorCall\(coordinator, request\.url, "state"\)/) - assert.ok(worker.indexOf('coordinatorCall(coordinator, request.url, "state")') < worker.indexOf("let lease = await acquireLease")) + assert.match(worker, /const state = await coordinator\.state\(\)/) + assert.ok(worker.indexOf("const state = await coordinator.state()") < worker.indexOf("let lease = await acquireLease")) assert.match(worker, /matchWordPressPageCache\(request, lease\.pointer, env\.WORDPRESS_STATE_BUCKET\)/) assert.match(worker, /cacheWordPressPage\(request, lease\.pointer, response, env\.WORDPRESS_STATE_BUCKET\)/) assert.match(worker, /__wp_codebox_revision/) - assert.match(worker, /R2_WORDPRESS_PAGE_PREFIX/) - assert.match(worker, /"wp-codebox\/wordpress-page\/v1"/) + assert.match(worker, /R2_PUBLISHED_CURRENT_KEY/) + assert.match(worker, /publishedPageObjectKey\(pointer\.revision, canonicalPublicRoute\(request\)\)/) + assert.match(worker, /PUBLISHED_PAGE_SCHEMA/) assert.match(worker, /"x-wp-codebox-page-cache"/) assert.match(worker, /"x-wp-codebox-page-cache-source"/) assert.match(worker, /"public, max-age=60, s-maxage=31536000"/) + assert.match(worker, /"public, max-age=60, s-maxage=60"/) + assert.match(worker, /publishCanonicalWordPressPages\(request, env, coordinator\)/) + assert.match(worker, /https:\/\/wp-codebox-publication\.invalid/) + assert.match(worker, /onlyIf: \{ etagDoesNotMatch: "\*" \}/) + assert.match(worker, /Immutable R2 object conflicts with existing content/) assert.match(worker, /serveWordPressUpload\(request, env\.WORDPRESS_STATE_BUCKET, coordinator\)/) assert.ok(worker.indexOf("serveWordPressUpload(request, env.WORDPRESS_STATE_BUCKET, coordinator)") < worker.indexOf("runCoordinatedWordPressRequest(request, env, coordinator, route.kind)")) assert.match(worker, /R2_UPLOAD_OBJECT_PREFIX/) @@ -505,7 +566,7 @@ test("Cloudflare coordinator serializes leases, promotes with CAS, and recovers test("serialized Cloudflare mutations use MDI flush paths and complete canonical state", async () => { const source = await readFile(new URL("../packages/runtime-cloudflare/src/worker.ts", import.meta.url), "utf8") - const mutation = source.slice(source.indexOf("const SERIALIZED_MARKDOWN_MUTATION_CODE"), source.indexOf("interface Env")) + const mutation = source.slice(source.indexOf("const SERIALIZED_MARKDOWN_MUTATION_CODE"), source.indexOf("export interface RuntimeEnv")) assert.match(mutation, /WP_Markdown_Primary_Storage_Runtime::bootstrap/) assert.match(mutation, /new WP_SQLite_Connection\(\['pdo' => \$GLOBALS\['@pdo'\], 'path' => FQDB\]\)/)