diff --git a/package-lock.json b/package-lock.json index a29e0c68..3de95ea1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5061,6 +5061,7 @@ "version": "0.12.27", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", + "@php-wasm/stream-compression": "^3.1.45", "@php-wasm/universal": "3.1.45", "@php-wasm/web-8-5": "3.1.45", "@wp-playground/wordpress": "3.1.45" diff --git a/package.json b/package.json index 3a229d52..af303451 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "wp-codebox:source": "node bin/wp-codebox-source.mjs", "generate:browser-fanout-aggregation-runtime": "tsx scripts/generate-browser-fanout-aggregation-runtime.ts", "generate:fanout-aggregation-contract": "tsx scripts/generate-fanout-aggregation-contract-fixture.ts", + "generate:cloudflare-mdi-runtime-bundle": "node scripts/build-cloudflare-mdi-runtime-bundle.mjs", "smoke": "tsx scripts/run-smoke.ts", "test:redaction": "tsx tests/redaction.test.ts", "test:cloudflare-runtime": "tsx tests/cloudflare-runtime.test.ts && node ./node_modules/typescript/bin/tsc -p packages/runtime-cloudflare --noEmit", diff --git a/packages/runtime-cloudflare/README.md b/packages/runtime-cloudflare/README.md index b7690d32..8952e10e 100644 --- a/packages/runtime-cloudflare/README.md +++ b/packages/runtime-cloudflare/README.md @@ -2,7 +2,9 @@ This additive integration is the first acceptance gate for [wp-codebox#1838](https://github.com/Automattic/wp-codebox/issues/1838). It compiles the current PHP 8.5 Asyncify Wasm asset for workerd, boots WordPress through Playground with the current SQLite integration release, executes PHP, and returns a Codebox runtime-command-result envelope containing the stable `wp-codebox/cloudflare-runtime-health/v1` health payload. -The Worker owns Cloudflare transport and isolate lifecycle. Full WordPress initialization requires the paid-plan five-minute CPU allowance, declared as `limits.cpu_ms: 300000`; Cloudflare's fixed 128 MB isolate memory limit remains unchanged. This gate uses one Playground PHP instance per isolate; Durable Objects will serialize site mutation in a later gate. The response identifies the generic `wordpress-playground` backend and `wordpress` environment; callers do not receive Cloudflare binding or limit details. Runtime snapshots, restore, R2, serialization, and cold restoration are deferred to later gates. +The Worker owns Cloudflare transport, PHP-WASM execution, and the caller-owned disposable SQLite cache. [MDI PR #126](https://github.com/Automattic/markdown-database-integration/pull/126), pinned at `94b9f875ffb8402d5e8eb726893a12324e20f45c`, supplies the constrained public primary runtime: normal MDI driver SQL writes are explicitly flushed to deterministic relative Markdown/JSON paths. R2 stores immutable canonical revisions and the current pointer; the Durable Object owns only the persisted lease, base-revision validation, and CAS pointer promotion. SQLite is never uploaded. This preserves cold reconstruction from canonical R2 files and concurrent mutation serialization without expanding MDI's storage-only boundary. + +Existing evidence covers full WordPress initialization and canonical R2 revision behavior. This update changes the source relationship from ad hoc writes to MDI's public constrained runtime, adds source-level bundle and mutation guards, and supports local packaging verification. It does not claim a new remote deployment. ## Verification diff --git a/packages/runtime-cloudflare/assets/markdown-database-integration-runtime.zip b/packages/runtime-cloudflare/assets/markdown-database-integration-runtime.zip new file mode 100644 index 00000000..80f0b63d Binary files /dev/null and b/packages/runtime-cloudflare/assets/markdown-database-integration-runtime.zip differ diff --git a/packages/runtime-cloudflare/assets/markdown-primary-bootstrap-index.sqlite b/packages/runtime-cloudflare/assets/markdown-primary-bootstrap-index.sqlite new file mode 100644 index 00000000..e1653d9d Binary files /dev/null and b/packages/runtime-cloudflare/assets/markdown-primary-bootstrap-index.sqlite differ diff --git a/packages/runtime-cloudflare/assets/wordpress-install-seed.sqlite b/packages/runtime-cloudflare/assets/wordpress-install-seed.sqlite new file mode 100644 index 00000000..3607f024 Binary files /dev/null and b/packages/runtime-cloudflare/assets/wordpress-install-seed.sqlite differ diff --git a/packages/runtime-cloudflare/package.json b/packages/runtime-cloudflare/package.json index c1711849..d0b7e661 100644 --- a/packages/runtime-cloudflare/package.json +++ b/packages/runtime-cloudflare/package.json @@ -5,6 +5,7 @@ "type": "module", "dependencies": { "@automattic/wp-codebox-core": "file:../runtime-core", + "@php-wasm/stream-compression": "^3.1.45", "@php-wasm/universal": "3.1.45", "@php-wasm/web-8-5": "3.1.45", "@wp-playground/wordpress": "3.1.45" diff --git a/packages/runtime-cloudflare/src/php-wasm-universal.d.ts b/packages/runtime-cloudflare/src/php-wasm-universal.d.ts index 68a6eb24..b4670c57 100644 --- a/packages/runtime-cloudflare/src/php-wasm-universal.d.ts +++ b/packages/runtime-cloudflare/src/php-wasm-universal.d.ts @@ -1,7 +1,13 @@ declare module "@php-wasm/universal" { export class PHP { constructor(runtimeId: number) - run(request: { code: string }): Promise<{ text: string }> + run(request: { code: string }): Promise<{ bytes: Uint8Array; text: string }> + isDir(path: string): boolean + listFiles(path: string): string[] + mkdir(path: string): void + readFileAsBuffer(path: string): Uint8Array + writeFile(path: string, data: Uint8Array): void + exit(code?: number): void } export function loadPHPRuntime(loader: { diff --git a/packages/runtime-cloudflare/src/sqlite.d.ts b/packages/runtime-cloudflare/src/sqlite.d.ts new file mode 100644 index 00000000..1417b41a --- /dev/null +++ b/packages/runtime-cloudflare/src/sqlite.d.ts @@ -0,0 +1,4 @@ +declare module "*.sqlite" { + const database: ArrayBuffer + export default database +} diff --git a/packages/runtime-cloudflare/src/worker.ts b/packages/runtime-cloudflare/src/worker.ts index 7096bd1b..10eca3bb 100644 --- a/packages/runtime-cloudflare/src/worker.ts +++ b/packages/runtime-cloudflare/src/worker.ts @@ -1,20 +1,93 @@ import { loadPHPRuntime, PHP } from "@php-wasm/universal" -import { bootWordPressAndRequestHandler } from "@wp-playground/wordpress" +import { decodeRemoteZip, decodeZip } from "@php-wasm/stream-compression" +import { bootWordPressAndRequestHandler, type WordPressInstallMode } from "@wp-playground/wordpress" // The PHP-WASM package publishes this Emscripten loader without TypeScript declarations. // @ts-expect-error The adjacent Wasm declaration covers the compiled binary import. import { dependenciesTotalSize, init } from "../../../node_modules/@php-wasm/web-8-5/asyncify/php_8_5.js" 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 markdownDatabaseIntegrationRuntime from "../assets/markdown-database-integration-runtime.zip" +import markdownPrimaryBootstrapIndex from "../assets/markdown-primary-bootstrap-index.sqlite" +import wordpressInstallSeed from "../assets/wordpress-install-seed.sqlite" const PHP_VERSION = "8.5.8" const WORDPRESS_ARCHIVE_URL = "https://wordpress.org/latest.zip" const SQLITE_INTEGRATION_ARCHIVE_URL = "https://github.com/WordPress/sqlite-database-integration/releases/download/v2.2.23/plugin-sqlite-database-integration.zip" +const MARKDOWN_DATABASE_INTEGRATION_REVISION = "94b9f875ffb8402d5e8eb726893a12324e20f45c" const SITE_URL = "https://wp-codebox-runtime.invalid" +const DATABASE_PATH = "/wordpress/wp-content/database/.ht.sqlite" +const MARKDOWN_ROOT = "/wordpress/wp-content/markdown" +const MARKDOWN_INDEX_PATH = "/tmp/markdown-index.sqlite" +const MARKDOWN_RESOLVED_INDEX_PATH = "/tmp/markdown-index-8133b4cf3c66.sqlite" +const R2_MARKDOWN_POINTER_KEY = "sites/default/markdown/current.json" +const R2_MARKDOWN_REVISION_PREFIX = "sites/default/markdown/revisions" +const R2_MARKDOWN_OBJECT_PREFIX = "sites/default/markdown/objects" +const MUTATION_LEASE_VERSION = "mdi-canonical-v1" +const SERIALIZED_MARKDOWN_MUTATION_CODE = `prefix; +$connection = new WP_SQLite_Connection(['pdo' => $GLOBALS['@pdo'], 'path' => FQDB]); +$runtime = WP_Markdown_Primary_Storage_Runtime::bootstrap( + ['content_root' => MARKDOWN_DB_CONTENT_DIR, 'state_root' => MARKDOWN_DB_STATE_DIR], + $connection, + defined('DB_NAME') && '' !== DB_NAME ? DB_NAME : 'database_name_here', + null, + true, + array_filter(array_map('trim', explode(',', MARKDOWN_DB_EXCLUDED_TYPES))), + $prefix +); +$driver = $runtime->get_driver(); +$option_rows = $driver->query("SELECT option_id, option_value FROM \`{\$prefix}options\` WHERE option_name = 'wp_codebox_mdi_revision'"); +$current = empty($option_rows) ? 0 : (int) $option_rows[0]->option_value; +$previous_rows = 0 === $current ? [] : $driver->query("SELECT ID FROM \`{\$prefix}posts\` WHERE post_name = 'cloudflare-r2-proof-$current'"); +$value = $current + 1; +$post_id_rows = $driver->query("SELECT COALESCE(MAX(ID), 0) + 1 AS post_id FROM \`{\$prefix}posts\`"); +$post_id = (int) $post_id_rows[0]->post_id; +$now = gmdate('Y-m-d H:i:s'); +$slug = 'cloudflare-r2-proof-' . $value; +$title = 'Cloudflare R2 Proof ' . $value; +$content = 'Persisted by MDI primary mode in R2 revision ' . $value . '.'; +$driver->query("INSERT INTO \`{\$prefix}posts\` (ID, post_author, post_date, post_date_gmt, post_content, post_title, post_excerpt, post_status, comment_status, ping_status, post_password, post_name, to_ping, pinged, post_modified, post_modified_gmt, post_content_filtered, post_parent, guid, menu_order, post_type, post_mime_type, comment_count) VALUES ($post_id, 0, '$now', '$now', '$content', '$title', '', 'publish', 'closed', 'closed', '', '$slug', '', '', '$now', '$now', '', 0, '', 0, 'post', '', 0)"); +if (empty($option_rows)) { + $driver->query("INSERT INTO \`{\$prefix}options\` (option_name, option_value, autoload) VALUES ('wp_codebox_mdi_revision', '$value', 'off')"); +} else { + $driver->query("UPDATE \`{\$prefix}options\` SET option_value = '$value', autoload = 'off' WHERE option_name = 'wp_codebox_mdi_revision'"); +} +$changes = $runtime->flush(); +echo json_encode(['revisionValue' => $value, 'previousPostFound' => !empty($previous_rows), 'postId' => $post_id, 'wordpressVersion' => $wp_version, 'canonicalChanges' => $changes]);` let bootPromise: Promise<{ php: PHP; wordpressVersion: string }> | undefined +interface Env { + WORDPRESS_STATE: DurableObjectNamespace + WORDPRESS_STATE_BUCKET: R2Bucket +} + export default { - async fetch(): Promise { - const runtime = await (bootPromise ??= bootWordPressRuntime()) + async fetch(request: Request, env: Env): Promise { + const phase = new URL(request.url).searchParams.get("phase") + if (phase === "r2-state" || phase === "r2-mutate") { + const expectedMethod = phase === "r2-mutate" ? "POST" : "GET" + if (request.method !== expectedMethod) { + return new Response(`WordPress state ${phase === "r2-mutate" ? "mutation" : "read"} requires ${expectedMethod}.`, { status: 405 }) + } + const coordinator = env.WORDPRESS_STATE.getByName("default") + return phase === "r2-state" + ? coordinator.fetch(new Request("https://coordinator/state")) + : runSerializedMarkdownMutation(env, coordinator) + } + if (phase) return runBootProbe(phase) + + const runtime = await (bootPromise ??= bootWordPressRuntime( + "do-not-attempt-installing", + true, + true, + new Uint8Array(wordpressInstallSeed), + )) const phpVersion = (await runtime.php.run({ code: " { +interface MarkdownPointer { + revision: string + manifestKey: string + persistedAt: string +} + +interface MarkdownManifestFile { + path: string + objectKey: string + sha256: string + size: number +} + +interface MarkdownManifest extends MarkdownPointer { + files: MarkdownManifestFile[] +} + +interface RuntimeFile { + path: string + bytes: Uint8Array +} + +interface MutationLease { + token: string + version: string + baseRevision: string | null + expiresAt: number +} + +interface AcquiredLease extends MutationLease { + pointer: MarkdownPointer | null +} + +export class WordPressStateCoordinator implements DurableObject { + private tail: Promise = Promise.resolve() + + constructor( + private readonly state: DurableObjectState, + private readonly env: Env, + ) {} + + fetch(request: Request): Promise { + const response = this.tail + .then(() => this.handleRequest(request)) + .catch((error: unknown) => Response.json({ + schema: "wp-codebox/cloudflare-wordpress-state-error/v1", + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + }, { status: 500 })) + this.tail = response.then(() => undefined, () => undefined) + return response + } + + private async handleRequest(request: Request): Promise { + const action = new URL(request.url).pathname + if (request.method === "GET" && action === "/state") { + const pointer = await readMarkdownPointer(this.env.WORDPRESS_STATE_BUCKET) + return Response.json({ + schema: "wp-codebox/cloudflare-wordpress-state/v1", + durableObjectId: this.state.id.toString(), + pointer, + }) + } + if (request.method !== "POST") return new Response("Method not allowed.", { status: 405 }) + + if (action === "/begin") { + const existing = await this.state.storage.get("mutation-lease") + if (existing?.version === MUTATION_LEASE_VERSION && existing.expiresAt > Date.now()) { + return Response.json({ retryAfterMs: 1_000 }, { status: 409 }) + } + const pointer = await readMarkdownPointer(this.env.WORDPRESS_STATE_BUCKET) + const lease: MutationLease = { + token: crypto.randomUUID(), + version: MUTATION_LEASE_VERSION, + baseRevision: pointer?.revision ?? null, + expiresAt: Date.now() + 2 * 60_000, + } + await this.state.storage.put("mutation-lease", lease) + return Response.json({ ...lease, pointer } satisfies AcquiredLease) + } + if (action === "/commit") { + const body = await request.json<{ token: string; pointer: MarkdownPointer }>() + const lease = await this.state.storage.get("mutation-lease") + if (!lease || lease.token !== body.token) return new Response("Mutation lease is invalid.", { status: 409 }) + const current = await readMarkdownPointer(this.env.WORDPRESS_STATE_BUCKET) + if ((current?.revision ?? null) !== lease.baseRevision) return new Response("Canonical revision changed during mutation.", { status: 409 }) + await this.env.WORDPRESS_STATE_BUCKET.put(R2_MARKDOWN_POINTER_KEY, JSON.stringify(body.pointer), { + httpMetadata: { contentType: "application/json" }, + }) + await this.state.storage.delete("mutation-lease") + return Response.json(body.pointer) + } + if (action === "/abort") { + const body = await request.json<{ token: string }>() + const lease = await this.state.storage.get("mutation-lease") + if (lease?.token === body.token) { + await this.state.storage.delete("mutation-lease") + } + return new Response(null, { status: 204 }) + } + return new Response("Unknown coordinator action.", { status: 404 }) + } +} + +async function runSerializedMarkdownMutation(env: Env, coordinator: DurableObjectStub): Promise { + const lease = await acquireMutationLease(coordinator) + try { + const markdownFiles = lease.pointer + ? await readMarkdownRevision(env.WORDPRESS_STATE_BUCKET, lease.pointer) + : initialMarkdownFiles() + const runtime = await bootWordPressRuntime( + "do-not-attempt-installing", + true, + true, + undefined, + markdownFiles, + new Uint8Array(markdownPrimaryBootstrapIndex), + ) + let canonicalFiles: RuntimeFile[] + let mutation: { revisionValue: number; previousPostFound: boolean; postId: number; wordpressVersion: string; canonicalChanges: MarkdownChanges } + try { + const mutationOutput = (await runtime.php.run({ + code: SERIALIZED_MARKDOWN_MUTATION_CODE, + })).text.trim() + mutation = JSON.parse(mutationOutput) as typeof mutation + validateMarkdownChanges(mutation.canonicalChanges) + canonicalFiles = collectRuntimeFiles(runtime.php, MARKDOWN_ROOT) + } finally { + runtime.php.exit() + } + + const nextPointer = await persistMarkdownRevision(env.WORDPRESS_STATE_BUCKET, canonicalFiles) + const commit = await coordinator.fetch(new Request("https://coordinator/commit", { + method: "POST", + body: JSON.stringify({ token: lease.token, pointer: nextPointer }), + })) + if (!commit.ok) throw new Error(`Unable to promote canonical Markdown revision: ${commit.status} ${await commit.text()}`) + return Response.json({ + schema: "wp-codebox/cloudflare-wordpress-mutation/v1", + source: lease.pointer ? "r2-markdown-revision" : "packaged-markdown-seed", + ...mutation, + canonicalFiles: canonicalFiles.length, + markdownDatabaseIntegrationRevision: MARKDOWN_DATABASE_INTEGRATION_REVISION, + sqlitePersisted: false, + pointer: nextPointer, + }) + } catch (error) { + await coordinator.fetch(new Request("https://coordinator/abort", { + method: "POST", + body: JSON.stringify({ token: lease.token }), + })) + throw error + } +} + +interface MarkdownChanges { + created: string[] + changed: string[] + deleted: string[] +} + +function validateMarkdownChanges(changes: MarkdownChanges): void { + for (const group of [changes.created, changes.changed, changes.deleted]) { + if (!Array.isArray(group) || group.some((path) => !isCanonicalRelativePath(path))) { + throw new Error("MDI flush returned an invalid canonical path.") + } + if (group.some((path, index) => index > 0 && group[index - 1] >= path)) { + throw new Error("MDI flush returned non-deterministic canonical paths.") + } + } +} + +function isCanonicalRelativePath(path: string): boolean { + return path.length > 0 && !path.startsWith("/") && !path.split("/").includes("..") +} + +async function acquireMutationLease(coordinator: DurableObjectStub): Promise { + for (let attempt = 0; attempt < 300; attempt++) { + const response = await coordinator.fetch(new Request("https://coordinator/begin", { method: "POST" })) + if (response.ok) return response.json() + if (response.status !== 409) throw new Error(`Unable to acquire mutation lease: ${response.status} ${await response.text()}`) + await new Promise((resolve) => setTimeout(resolve, 1_000)) + } + throw new Error("Timed out waiting for the WordPress mutation lease.") +} + +async function readMarkdownPointer(bucket: R2Bucket): Promise { + const object = await bucket.get(R2_MARKDOWN_POINTER_KEY) + return object ? object.json() : null +} + +async function readMarkdownRevision(bucket: R2Bucket, pointer: MarkdownPointer): Promise { + const manifestObject = await bucket.get(pointer.manifestKey) + if (!manifestObject) throw new Error(`R2 Markdown manifest is missing: ${pointer.manifestKey}`) + const manifest = await manifestObject.json() + const files: RuntimeFile[] = [] + for (const file of manifest.files) { + const object = await bucket.get(file.objectKey) + if (!object) throw new Error(`R2 Markdown object is missing: ${file.objectKey}`) + files.push({ path: file.path, bytes: new Uint8Array(await object.arrayBuffer()) }) + } + return files +} + +async function persistMarkdownRevision(bucket: R2Bucket, files: RuntimeFile[]): Promise { + const manifestFiles: MarkdownManifestFile[] = [] + for (const file of files) { + const sha256 = await sha256Hex(file.bytes) + const objectKey = `${R2_MARKDOWN_OBJECT_PREFIX}/${sha256}` + if (!await bucket.head(objectKey)) await bucket.put(objectKey, file.bytes) + manifestFiles.push({ path: file.path, objectKey, sha256, size: file.bytes.byteLength }) + } + + const revision = crypto.randomUUID() + const manifestKey = `${R2_MARKDOWN_REVISION_PREFIX}/${revision}.json` + const persistedAt = new Date().toISOString() + const pointer: MarkdownPointer = { revision, manifestKey, persistedAt } + const manifest: MarkdownManifest = { ...pointer, files: manifestFiles } + await bucket.put(manifestKey, JSON.stringify(manifest), { + httpMetadata: { contentType: "application/json" }, + }) + return pointer +} + +async function runBootProbe(phase: string): Promise { + if (phase === "wordpress-archive" || phase === "sqlite-archive") { + const archive = phase === "wordpress-archive" + ? await fetchArchive(WORDPRESS_ARCHIVE_URL, "wordpress.zip") + : await fetchArchive(SQLITE_INTEGRATION_ARCHIVE_URL, "sqlite-database-integration.zip") + return probeResponse(phase, { archiveBytes: archive.size }) + } + + if (phase === "archives") { + const wordpressZip = await fetchArchive(WORDPRESS_ARCHIVE_URL, "wordpress.zip") + const sqliteZip = await fetchArchive(SQLITE_INTEGRATION_ARCHIVE_URL, "sqlite-database-integration.zip") + return probeResponse(phase, { wordpressArchiveBytes: wordpressZip.size, sqliteArchiveBytes: sqliteZip.size }) + } + + if (phase === "php") { + const php = new PHP(await createPhpRuntime()) + try { + const phpVersion = (await php.run({ code: " file_exists('/wordpress/wp-content/db.php'), 'storage' => file_exists('/wordpress/wp-content/plugins/markdown-database-integration/inc/class-wp-markdown-storage.php'), 'siteurl' => file_exists('/wordpress/wp-content/markdown/_options/siteurl.json')]);", + })).text.trim() + return probeResponse(phase, { ...wordpress, ...JSON.parse(evidence) as Record }) + } finally { + php.exit() + } + } + + if (phase === "mdi-shortinit") { + const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex)) + try { + const evidence = (await runtime.php.run({ + code: " $wp_version, 'markdownDropin' => defined('MARKDOWN_DB_DROPIN'), 'markdownMode' => defined('MARKDOWN_DB_MODE') ? MARKDOWN_DB_MODE : '']);", + })).text.trim() + return probeResponse(phase, JSON.parse(evidence) as Record) + } finally { + runtime.php.exit() + } + } + + if (phase === "mdi-wordpress" || phase === "mdi-option" || phase === "mdi-insert") { + const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex)) + try { + const operation = phase === "mdi-option" + ? "$updated = update_option('wp_codebox_mdi_probe', 1); $result = ['updated' => $updated];" + : phase === "mdi-insert" + ? "$post_id = wp_insert_post(['post_title' => 'MDI Probe', 'post_name' => 'mdi-probe', 'post_content' => 'MDI probe body.', 'post_status' => 'publish', 'post_type' => 'post'], true); if (is_wp_error($post_id)) { throw new Exception($post_id->get_error_message()); } $result = ['postId' => $post_id];" + : "$result = [];" + const evidence = (await runtime.php.run({ + code: ` get_bloginfo('version')], $result));`, + })).text.trim() + return probeResponse(phase, JSON.parse(evidence) as Record) + } finally { + runtime.php.exit() + } + } + + if (["mdi-includes", "mdi-embed", "mdi-textdomain", "mdi-ai-client", "mdi-plugin-constants", "mdi-muplugins", "mdi-plugins", "mdi-globals", "mdi-theme", "mdi-site-health-class", "mdi-site-health", "mdi-current-user", "mdi-init", "mdi-wp-loaded"].includes(phase)) { + const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles(), new Uint8Array(markdownPrimaryBootstrapIndex)) + try { + const evidence = (await runtime.php.run({ code: wordpressProbeCode(phase) })).text.trim() + return probeResponse(phase, JSON.parse(evidence) as Record) + } finally { + runtime.php.exit() + } + } + + if (phase?.startsWith("seeded-")) { + const runtime = await bootWordPressRuntime( + "do-not-attempt-installing", + true, + true, + new Uint8Array(wordpressInstallSeed), + ) + try { + const wordpress = (await runtime.php.run({ + code: wordpressProbeCode(phase), + })).text.trim() + try { + return probeResponse(phase, JSON.parse(wordpress) as Record) + } catch { + return Response.json({ + schema: "wp-codebox/cloudflare-boot-probe/v1", + phase, + completed: false, + evidence: { rawPhpOutput: wordpress }, + }, { status: 500 }) + } + } finally { + runtime.php.exit() + } + } + + if (phase === "wordpress-files" || phase === "sqlite" || phase === "full" || phase === "streamed-sqlite" || phase === "streamed-wordpress") { + const runtime = await bootWordPressRuntime( + phase === "full" || phase === "streamed-wordpress" ? "install-from-existing-files" : "do-not-attempt-installing", + phase !== "wordpress-files", + phase === "streamed-sqlite" || phase === "streamed-wordpress", + ) + try { + const phpVersion = (await runtime.php.run({ code: " $wp_version, 'shortInit' => true]);" + } + if (phase === "seeded-wordpress") { + return " get_option('siteurl'), 'wordpressVersion' => get_bloginfo('version')]);" + } + + const stops: Record = { + "seeded-includes": { needle: "add_action( 'after_setup_theme', array( wp_script_modules(), 'add_hooks' ) );" }, + "seeded-embed": { needle: "/**\n * WordPress Textdomain Registry object." }, + "seeded-textdomain": { needle: "// WordPress AI Client initialization." }, + "seeded-ai-client": { needle: "// Load multisite-specific files." }, + "seeded-plugin-constants": { needle: "// Load must-use plugins." }, + "seeded-muplugins": { needle: "if ( is_multisite() ) {\n\tms_cookie_constants();" }, + "seeded-plugins": { needle: "// Define constants which affect functionality if not already defined." }, + "seeded-globals": { needle: "/**\n * Fires before the theme is loaded." }, + "seeded-theme": { needle: "// Create an instance of WP_Site_Health so that Cron events may fire." }, + "seeded-site-health-class": { needle: "WP_Site_Health::get_instance();" }, + "seeded-site-health": { needle: "// Set up current user." }, + "seeded-current-user": { needle: "/**\n * Fires after WordPress has finished loading but before any headers are sent." }, + "seeded-init": { needle: "// Check site status." }, + "seeded-wp-loaded": { needle: "do_action( 'wp_loaded' );", after: true }, + } + const stop = stops[phase.replace(/^mdi-/, "seeded-")] + if (!stop) throw new Error(`Unknown seeded WordPress probe phase: ${phase}`) + + return ` \\$wp_version, 'bootstrapPhase' => '${phase}', 'memoryBytes' => memory_get_usage(true), 'peakMemoryBytes' => memory_get_peak_usage(true), 'markdownIndexExists' => file_exists('${MARKDOWN_RESOLVED_INDEX_PATH}')]); return;\n"; +$replacement = ${stop.after ? "$needle . \"\\n\" . $stop" : "$stop . $needle"}; +file_put_contents($settings_path, str_replace($needle, $replacement, $settings)); +require '/wordpress/wp-load.php';` +} + +async function bootWordPressRuntime( + wordpressInstallMode: WordPressInstallMode = "install-from-existing-files", + includeSqlite = true, + streamWordPressFiles = false, + databaseSeed?: Uint8Array, + markdownFiles?: RuntimeFile[], + markdownIndexSeed?: Uint8Array, +): Promise<{ php: PHP; wordpressVersion: string }> { const requestHandler = await bootWordPressAndRequestHandler({ - createPhpRuntime: () => loadPHPRuntime({ dependencyFilename: "php_8_5.wasm", dependenciesTotalSize, phpWasmAsyncMode: "asyncify", init }, { instantiateWasm: instantiatePrecompiledWasm(phpWasmModule) }), + createPhpRuntime, + constants: { + AUTOMATIC_UPDATER_DISABLED: true, + DISABLE_WP_CRON: true, + ...(markdownFiles ? { + MARKDOWN_DB_CONTENT_DIR: MARKDOWN_ROOT, + MARKDOWN_DB_EXCLUDED_TYPES: "revision,auto-draft,nav_menu_item,customize_changeset,oembed_cache,wp_navigation,wp_global_styles,wp_template,wp_template_part", + MARKDOWN_DB_INDEX_PATH: MARKDOWN_INDEX_PATH, + MARKDOWN_DB_MODE: "primary", + MARKDOWN_DB_STATE_DIR: MARKDOWN_ROOT, + MARKDOWN_DB_VERSION: "0.8.3", + } : {}), + WP_HTTP_BLOCK_EXTERNAL: true, + }, + dataSqlPath: DATABASE_PATH, + hooks: streamWordPressFiles || databaseSeed || markdownFiles ? { + beforeWordPressFiles: streamWordPressFiles || markdownFiles ? async (php: PHP) => { + if (streamWordPressFiles) await materializeWordPressServerFiles(php) + if (markdownFiles) { + await materializeMarkdownDatabaseIntegration(php) + materializeRuntimeFiles(php, MARKDOWN_ROOT, markdownFiles) + if (markdownIndexSeed) php.writeFile(MARKDOWN_RESOLVED_INDEX_PATH, markdownIndexSeed) + } + } : undefined, + beforeDatabaseSetup: databaseSeed ? (php: PHP) => { + php.mkdir("/wordpress/wp-content/database") + php.writeFile(DATABASE_PATH, databaseSeed) + } : undefined, + } : undefined, maxPhpInstances: 1, phpVersion: "8.5", siteUrl: SITE_URL, - wordPressZip: fetchArchive(WORDPRESS_ARCHIVE_URL, "wordpress.zip"), - sqliteIntegrationPluginZip: fetchArchive(SQLITE_INTEGRATION_ARCHIVE_URL, "sqlite-database-integration.zip"), - wordpressInstallMode: "install-from-existing-files", + wordPressZip: streamWordPressFiles ? undefined : fetchArchive(WORDPRESS_ARCHIVE_URL, "wordpress.zip"), + sqliteIntegrationPluginZip: includeSqlite ? fetchArchive(SQLITE_INTEGRATION_ARCHIVE_URL, "sqlite-database-integration.zip") : undefined, + wordpressInstallMode, }) const php = await requestHandler.getPrimaryPhp() const wordpressVersion = (await php.run({ code: " ({ + path: `_options/${option.option_name}.json`, + bytes: new TextEncoder().encode(JSON.stringify(option, null, 2)), + })) +} + +function materializeRuntimeFiles(php: PHP, root: string, files: RuntimeFile[]): void { + for (const file of files) { + const destination = `${root}/${file.path}` + php.mkdir(destination.slice(0, destination.lastIndexOf("/"))) + php.writeFile(destination, file.bytes) + } +} + +function collectRuntimeFiles(php: PHP, root: string): RuntimeFile[] { + const files: RuntimeFile[] = [] + const visit = (directory: string): void => { + for (const name of php.listFiles(directory)) { + if (name === "." || name === "..") continue + const path = `${directory}/${name}` + if (php.isDir(path)) { + visit(path) + } else if (!name.includes(".tmp.") && !name.startsWith("markdown-index.sqlite")) { + files.push({ path: path.slice(root.length + 1), bytes: php.readFileAsBuffer(path) }) + } + } + } + visit(root) + return files.sort((left, right) => left.path.localeCompare(right.path)) +} + +async function sha256Hex(bytes: Uint8Array): Promise { + const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer) + return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("") +} + +async function materializeMarkdownDatabaseIntegration(php: PHP): Promise { + const stream = decodeZip(new Blob([markdownDatabaseIntegrationRuntime]).stream()) + const reader = stream.getReader() + while (true) { + const { done, value: entry } = await reader.read() + if (done) break + const relative = entry.name + const bytes = new Uint8Array(await entry.arrayBuffer()) + const destination = `/wordpress/wp-content/plugins/markdown-database-integration/${relative}` + php.mkdir(destination.slice(0, destination.lastIndexOf("/"))) + php.writeFile(destination, bytes) + if (relative === "db.php") php.writeFile("/wordpress/wp-content/db.php", bytes) + } +} + +function archiveRelativePath(path: string): string { + const separator = path.indexOf("/") + return separator === -1 ? "" : path.slice(separator + 1) +} + +async function materializeWordPressServerFiles(php: PHP): Promise<{ materializedFiles: number; materializedBytes: number }> { + const decoder = new TextDecoder() + let materializedFiles = 0 + let materializedBytes = 0 + const stream = await decodeRemoteZip(WORDPRESS_ARCHIVE_URL, (entry: { path: Uint8Array }) => isWordPressServerFile(decoder.decode(entry.path))) + const reader = stream.getReader() + while (true) { + const { done, value: entry } = await reader.read() + if (done) break + const path = entry instanceof File ? entry.name : decoder.decode(entry.path) + if (!path.startsWith("wordpress/") || path.endsWith("/")) continue + + const destination = `/${path}` + const bytes = entry instanceof File ? new Uint8Array(await entry.arrayBuffer()) : entry.bytes + php.mkdir(destination.slice(0, destination.lastIndexOf("/"))) + php.writeFile(destination, bytes) + materializedFiles++ + materializedBytes += bytes.byteLength + } + return { materializedFiles, materializedBytes } +} + +function isWordPressServerFile(path: string): boolean { + if (path.startsWith("wordpress/wp-admin/")) { + return path === "wordpress/wp-admin/includes/plugin.php" + || path === "wordpress/wp-admin/includes/class-wp-site-health.php" + } + return /\.(?:php|json|crt|html)$/.test(path) + || path.endsWith("/style.css") +} + +function createPhpRuntime() { + return loadPHPRuntime( + { dependencyFilename: "php_8_5.wasm", dependenciesTotalSize, phpWasmAsyncMode: "asyncify", init }, + { instantiateWasm: instantiatePrecompiledWasm(phpWasmModule) }, + ) +} + async function fetchArchive(url: string, name: string): Promise { const response = await fetch(url) if (!response.ok) throw new Error(`Unable to fetch ${name}: ${response.status}.`) @@ -52,3 +670,7 @@ async function fetchArchive(url: string, name: string): Promise { function instantiatePrecompiledWasm(module: WebAssembly.Module) { return (imports: WebAssembly.Imports, receiveInstance: (instance: WebAssembly.Instance, wasmModule: WebAssembly.Module) => void) => receiveInstance(new WebAssembly.Instance(module, imports), module) } + +function probeResponse(phase: string, evidence: Record): Response { + return Response.json({ schema: "wp-codebox/cloudflare-boot-probe/v1", phase, completed: true, evidence }) +} diff --git a/packages/runtime-cloudflare/src/zip.d.ts b/packages/runtime-cloudflare/src/zip.d.ts new file mode 100644 index 00000000..bfc8135c --- /dev/null +++ b/packages/runtime-cloudflare/src/zip.d.ts @@ -0,0 +1,4 @@ +declare module "*.zip" { + const archive: ArrayBuffer + export default archive +} diff --git a/packages/runtime-cloudflare/wrangler.jsonc b/packages/runtime-cloudflare/wrangler.jsonc index 1af2c80e..9072a4c7 100644 --- a/packages/runtime-cloudflare/wrangler.jsonc +++ b/packages/runtime-cloudflare/wrangler.jsonc @@ -4,5 +4,19 @@ "compatibility_date": "2026-07-18", "compatibility_flags": ["nodejs_compat"], "limits": { "cpu_ms": 300000 }, - "rules": [{ "type": "CompiledWasm", "globs": ["**/*.wasm"], "fallthrough": false }] + "r2_buckets": [ + { "binding": "WORDPRESS_STATE_BUCKET", "bucket_name": "wp-codebox-runtime-chubes" } + ], + "durable_objects": { + "bindings": [ + { "name": "WORDPRESS_STATE", "class_name": "WordPressStateCoordinator" } + ] + }, + "migrations": [ + { "tag": "v1", "new_sqlite_classes": ["WordPressStateCoordinator"] } + ], + "rules": [ + { "type": "CompiledWasm", "globs": ["**/*.wasm"], "fallthrough": false }, + { "type": "Data", "globs": ["**/*.sqlite", "**/*-runtime.zip"], "fallthrough": false } + ] } diff --git a/scripts/build-cloudflare-mdi-runtime-bundle.mjs b/scripts/build-cloudflare-mdi-runtime-bundle.mjs new file mode 100644 index 00000000..bc4ef407 --- /dev/null +++ b/scripts/build-cloudflare-mdi-runtime-bundle.mjs @@ -0,0 +1,33 @@ +import { writeFile } from "node:fs/promises" +import { decodeZip, encodeZip } from "@php-wasm/stream-compression" + +const revision = "94b9f875ffb8402d5e8eb726893a12324e20f45c" +const archiveUrl = `https://codeload.github.com/Automattic/markdown-database-integration/zip/${revision}` +const output = new URL("../packages/runtime-cloudflare/assets/markdown-database-integration-runtime.zip", import.meta.url) +const response = await fetch(archiveUrl) +if (!response.ok || !response.body) throw new Error(`Unable to fetch Markdown Database Integration: ${response.status}.`) + +const runtimePaths = new Set([ + "db.php", + "inc/class-wp-markdown-db.php", + "inc/class-wp-markdown-driver.php", + "inc/class-wp-markdown-frontmatter-profiles.php", + "inc/class-wp-markdown-loader.php", + "inc/class-wp-markdown-primary-storage-runtime.php", + "inc/class-wp-markdown-search.php", + "inc/class-wp-markdown-storage.php", + "inc/class-wp-markdown-write-engine.php", +]) +const runtimeFiles = [] +for await (const entry of decodeZip(response.body)) { + const separator = entry.name.indexOf("/") + const relative = separator === -1 ? "" : entry.name.slice(separator + 1) + if (!runtimePaths.has(relative)) continue + runtimeFiles.push(new File([await entry.arrayBuffer()], relative, { lastModified: 0 })) +} +if (runtimeFiles.length !== runtimePaths.size) throw new Error(`Expected ${runtimePaths.size} MDI runtime files, received ${runtimeFiles.length}.`) + +runtimeFiles.sort((left, right) => left.name.localeCompare(right.name)) +const archive = await new Response(encodeZip(runtimeFiles)).arrayBuffer() +await writeFile(output, new Uint8Array(archive)) +console.log(`Bundled ${runtimeFiles.length} MDI runtime files from ${revision} (${archive.byteLength} bytes).`) diff --git a/tests/cloudflare-runtime.test.ts b/tests/cloudflare-runtime.test.ts index 8177cf97..fb8add81 100644 --- a/tests/cloudflare-runtime.test.ts +++ b/tests/cloudflare-runtime.test.ts @@ -1,6 +1,7 @@ import assert from "node:assert/strict" import { readFile } from "node:fs/promises" import test from "node:test" +import { decodeZip } 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" @@ -28,3 +29,59 @@ test("Cloudflare runtime declares the paid-plan WordPress boot CPU budget", asyn const config = JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/wrangler.jsonc", import.meta.url), "utf8")) as { limits?: { cpu_ms?: number } } assert.equal(config.limits?.cpu_ms, 300_000) }) + +test("Cloudflare runtime packages the disposable WordPress install seed", async () => { + const config = JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/wrangler.jsonc", import.meta.url), "utf8")) as { + rules?: Array<{ type?: string; globs?: string[] }> + r2_buckets?: Array<{ binding?: string; bucket_name?: string }> + durable_objects?: { bindings?: Array<{ name?: string; class_name?: string }> } + migrations?: Array<{ new_sqlite_classes?: string[] }> + } + const seed = await readFile(new URL("../packages/runtime-cloudflare/assets/wordpress-install-seed.sqlite", import.meta.url)) + const markdownIndex = await readFile(new URL("../packages/runtime-cloudflare/assets/markdown-primary-bootstrap-index.sqlite", import.meta.url)) + const markdownRuntime = await readFile(new URL("../packages/runtime-cloudflare/assets/markdown-database-integration-runtime.zip", import.meta.url)) + + assert.equal(seed.subarray(0, 16).toString(), "SQLite format 3\0") + assert.equal(markdownIndex.subarray(0, 16).toString(), "SQLite format 3\0") + assert.equal(markdownRuntime.subarray(0, 4).toString("hex"), "504b0304") + assert.ok(config.rules?.some((rule) => rule.type === "Data" && rule.globs?.includes("**/*.sqlite"))) + assert.ok(config.rules?.some((rule) => rule.type === "Data" && rule.globs?.includes("**/*-runtime.zip"))) + assert.deepEqual(config.r2_buckets, [{ binding: "WORDPRESS_STATE_BUCKET", bucket_name: "wp-codebox-runtime-chubes" }]) + assert.deepEqual(config.durable_objects?.bindings, [{ name: "WORDPRESS_STATE", class_name: "WordPressStateCoordinator" }]) + assert.ok(config.migrations?.some((migration) => migration.new_sqlite_classes?.includes("WordPressStateCoordinator"))) +}) + +test("Cloudflare runtime pins and bundles the public constrained MDI runtime", async () => { + const revision = "94b9f875ffb8402d5e8eb726893a12324e20f45c" + const generator = await readFile(new URL("../scripts/build-cloudflare-mdi-runtime-bundle.mjs", import.meta.url), "utf8") + const worker = await readFile(new URL("../packages/runtime-cloudflare/src/worker.ts", import.meta.url), "utf8") + const runtime = await readFile(new URL("../packages/runtime-cloudflare/assets/markdown-database-integration-runtime.zip", import.meta.url)) + const names: string[] = [] + for await (const entry of decodeZip(new Blob([runtime]).stream())) names.push(entry.name) + + assert.match(generator, new RegExp(`const revision = "${revision}"`)) + assert.match(worker, new RegExp(`MARKDOWN_DATABASE_INTEGRATION_REVISION = "${revision}"`)) + assert.deepEqual(names.sort(), [ + "db.php", + "inc/class-wp-markdown-db.php", + "inc/class-wp-markdown-driver.php", + "inc/class-wp-markdown-frontmatter-profiles.php", + "inc/class-wp-markdown-loader.php", + "inc/class-wp-markdown-primary-storage-runtime.php", + "inc/class-wp-markdown-search.php", + "inc/class-wp-markdown-storage.php", + "inc/class-wp-markdown-write-engine.php", + ]) +}) + +test("serialized Cloudflare mutations use the public MDI runtime and its flush paths", 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("let bootPromise")) + + assert.match(mutation, /WP_Markdown_Primary_Storage_Runtime::bootstrap/) + assert.match(mutation, /new WP_SQLite_Connection\(\['pdo' => \$GLOBALS\['@pdo'\], 'path' => FQDB\]\)/) + assert.match(mutation, /\$runtime->get_driver\(\)/) + assert.match(mutation, /\$runtime->flush\(\)/) + assert.doesNotMatch(mutation, /\$wpdb->dbh|\{\$\{prefix\}|WP_Markdown_Storage|write_post|file_put_contents|wp_codebox_mdi_revision\.json/) + assert.match(source, /validateMarkdownChanges\(mutation\.canonicalChanges\)/) +})