Skip to content

Commit cee31d5

Browse files
committed
Load Cloudflare runtime dependencies from R2
1 parent b90b8b4 commit cee31d5

7 files changed

Lines changed: 117 additions & 14 deletions

packages/runtime-cloudflare/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,15 @@ The Worker forwards browser cookies directly to Playground and disables Playgrou
1616

1717
The bundled MDI source is pinned to immutable commit `2a8ee7f6a46e1d64b4606f1ee3c97e14032dc96c` from [MDI PR #139](https://github.com/Automattic/markdown-database-integration/pull/139). The bundle generator, worker provenance, and source-contract test use the same revision.
1818

19-
WordPress server files and browser assets are separate deployment artifacts, never Worker-module imports. `generate:cloudflare-wordpress-runtime-corpus` downloads the pinned WordPress release once. It emits a deterministic server ZIP containing only `isWordPressRuntimeFile()` entries and one concatenated browser-asset blob containing the complete supported `wp-admin`, `wp-includes`, and bundled-theme surface. Checked-in manifests record the content-addressed R2 keys, hashes, budgets, and each browser asset's exact byte range. Cold boot validates and streams the server ZIP into PHP MEMFS. Browser requests use one bounded R2 range read, validate the selected bytes, and populate an immutable artifact-versioned Worker cache without fetching or decoding the WordPress release at request time.
19+
WordPress server files, browser assets, and pinned runtime dependencies are separate deployment artifacts, never Worker-module imports. `generate:cloudflare-wordpress-runtime-corpus` emits a deterministic server ZIP containing only `isWordPressRuntimeFile()` entries, one concatenated browser-asset blob containing the complete supported `wp-admin`, `wp-includes`, and bundled-theme surface, and the pinned SQLite integration ZIP. Checked-in manifests record content-addressed R2 keys, hashes, budgets, provenance, and each browser asset's exact byte range. Cold boot validates and streams the server ZIP into PHP MEMFS while loading SQLite integration from R2. Browser requests use one bounded R2 range read, validate the selected bytes, and populate an immutable artifact-versioned Worker cache. Runtime requests never fetch WordPress or SQLite integration release archives from third-party origins.
2020

2121
Canonical Cloudflare boots patch only the assembled PHP MEMFS copy of `/wordpress/wp-settings.php` after the R2 WordPress corpus is materialized and before WordPress executes. The disabled-cron scheduling policy requires exactly one canonical `do_action( 'init' );` needle and fails closed otherwise. When `DISABLE_WP_CRON` is true, it removes only core's `wp_cron`, `wp_schedule_delete_old_privacy_export_files`, and `wp_schedule_update_checks` callbacks through public `remove_action()` before executing the original `init` call once. This prevents browser requests from recreating cron work that cannot execute in the PHP-WASM/MDI shutdown path. Durable cron execution belongs to a separate explicit queue or scheduled-Worker contract.
2222
## Verification
2323

2424
1. Run `npm run generate:cloudflare-canonical-mdi-seed` and `npm run generate:cloudflare-wordpress-runtime-corpus` to regenerate deterministic runtime artifacts and manifests.
25-
2. Run `npm run provision:cloudflare-wordpress-runtime-corpus -- --local --persist-to <directory>` to verify and upload both exact content-addressed artifacts into isolated local R2 storage. For an authorized deployment, run the provisioner with `--remote` and require both uploads to succeed before deploying the Worker that imports their manifests.
25+
2. Run `npm run provision:cloudflare-wordpress-runtime-corpus -- --local --persist-to <directory>` to verify and upload all exact content-addressed artifacts into isolated local R2 storage. For an authorized deployment, run the provisioner with `--remote` and require every upload to succeed before deploying the Worker that imports their manifests.
2626
3. Run `npm run test:cloudflare-runtime` for routing, canonical-state, artifact validation, source contract, and TypeScript coverage.
2727
4. Run `npm run cloudflare:dry-run` to compile the Worker without creating Cloudflare resources.
28-
5. Run `npm run cloudflare:local-gate` for isolated local workerd evidence. It generates and provisions both artifacts before workerd starts, then injects stable test-only admin-password and auth-secret values, verifies the login form, cookie-authenticated admin redirect, authenticated REST post publication, public rendering, representative frontend/admin/editor assets, PHP diagnostics, an existing authenticated cookie after cold restart, and a fresh login after restart.
28+
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, verifies the login form, cookie-authenticated admin redirect, authenticated REST post publication, public rendering, representative frontend/admin/editor assets, PHP diagnostics, an existing authenticated cookie after cold restart, and a fresh login after restart.
2929

3030
This document describes local candidate verification only. It does not claim remote deployment.
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
{
2+
"schema": "wp-codebox/runtime-archive-artifact/v1",
3+
"name": "sqlite-database-integration",
4+
"key": "runtime/archives/sqlite-database-integration/f3bf0ac0bd80d374456c3742c3e2f987d060e8e35f4fe99925de2404ce05cbfb.zip",
5+
"archive": {
6+
"sha256": "f3bf0ac0bd80d374456c3742c3e2f987d060e8e35f4fe99925de2404ce05cbfb",
7+
"size": 267173
8+
},
9+
"source": {
10+
"url": "https://github.com/WordPress/sqlite-database-integration/releases/download/v2.2.23/plugin-sqlite-database-integration.zip",
11+
"version": "2.2.23",
12+
"identity": "\"0x8DE9712B1086587\""
13+
}
14+
}
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
export const RUNTIME_ARCHIVE_ARTIFACT_SCHEMA = "wp-codebox/runtime-archive-artifact/v1"
2+
export const RUNTIME_ARCHIVE_MAX_BYTES = 8 * 1024 * 1024
3+
4+
export interface RuntimeArchiveArtifactManifest {
5+
schema: typeof RUNTIME_ARCHIVE_ARTIFACT_SCHEMA
6+
name: string
7+
key: string
8+
archive: { sha256: string; size: number }
9+
source: { url: string; version?: string; identity?: string }
10+
}
11+
12+
export function runtimeArchiveArtifactKey(name: string, sha256: string): string {
13+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)) throw new Error("Runtime archive artifact name is invalid.")
14+
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Runtime archive artifact hash must be a SHA-256 digest.")
15+
return `runtime/archives/${name}/${sha256}.zip`
16+
}
17+
18+
export function validateRuntimeArchiveArtifactManifest(manifest: RuntimeArchiveArtifactManifest): void {
19+
if (manifest.schema !== RUNTIME_ARCHIVE_ARTIFACT_SCHEMA) throw new Error("Runtime archive artifact schema is invalid.")
20+
if (manifest.key !== runtimeArchiveArtifactKey(manifest.name, manifest.archive.sha256)) throw new Error("Runtime archive artifact key is not content addressed.")
21+
if (!Number.isSafeInteger(manifest.archive.size) || manifest.archive.size < 1 || manifest.archive.size > RUNTIME_ARCHIVE_MAX_BYTES) throw new Error("Runtime archive artifact size is outside the allowed budget.")
22+
if (!manifest.source.url.startsWith("https://")) throw new Error("Runtime archive artifact source URL is invalid.")
23+
}
24+
25+
export async function readRuntimeArchiveArtifact(bucket: R2Bucket, manifest: RuntimeArchiveArtifactManifest): Promise<Uint8Array> {
26+
validateRuntimeArchiveArtifactManifest(manifest)
27+
const object = await bucket.get(manifest.key)
28+
if (!object) throw new Error(`Runtime archive artifact is unavailable: ${manifest.key}`)
29+
if (object.size !== manifest.archive.size) throw new Error("Runtime archive artifact size does not match its manifest.")
30+
const bytes = new Uint8Array(await object.arrayBuffer())
31+
if (await sha256Hex(bytes) !== manifest.archive.sha256) throw new Error("Runtime archive artifact hash does not match its manifest.")
32+
return bytes
33+
}
34+
35+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
36+
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer)
37+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
38+
}

packages/runtime-cloudflare/src/worker.ts

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { deriveWordPressAuthConstants, type WordPressAuthConstant } from "./word
1414
import { isWordPressRuntimeFile, wordpressStaticArchivePath, wordpressStaticContentType } from "./wordpress-runtime-corpus.js"
1515
import { materializeWordPressRuntimeArtifact, type WordPressRuntimeArtifactManifest } from "./wordpress-runtime-artifact.js"
1616
import { validateWordPressStaticArtifactManifest, type WordPressStaticArtifactManifest } from "./wordpress-static-artifact.js"
17+
import { readRuntimeArchiveArtifact, type RuntimeArchiveArtifactManifest } from "./runtime-archive-artifact.js"
1718
import type { MarkdownPointer } from "./state-coordinator.js"
1819
export { WordPressStateCoordinator } from "./state-coordinator.js"
1920
import markdownDatabaseIntegrationRuntime from "../assets/markdown-database-integration-runtime.zip"
@@ -23,12 +24,12 @@ import markdownPrimaryBootstrapIndex from "../assets/markdown-primary-bootstrap-
2324
import wordpressInstallSeed from "../assets/wordpress-install-seed.sqlite"
2425
import wordpressRuntimeArtifactManifest from "../assets/wordpress-runtime-artifact.json" with { type: "json" }
2526
import wordpressStaticArtifactManifest from "../assets/wordpress-static-artifact.json" with { type: "json" }
27+
import sqliteIntegrationArtifactManifest from "../assets/sqlite-database-integration-artifact.json" with { type: "json" }
2628

2729
const PHP_VERSION = "8.5.8"
2830
const wordpressStaticArtifact = wordpressStaticArtifactManifest as WordPressStaticArtifactManifest
2931
validateWordPressStaticArtifactManifest(wordpressStaticArtifact)
3032
const wordpressStaticFiles = new Map(wordpressStaticArtifact.files.map((file) => [file.path, file]))
31-
const SQLITE_INTEGRATION_ARCHIVE_URL = "https://github.com/WordPress/sqlite-database-integration/releases/download/v2.2.23/plugin-sqlite-database-integration.zip"
3233
const MARKDOWN_DATABASE_INTEGRATION_REVISION = "2a8ee7f6a46e1d64b4606f1ee3c97e14032dc96c"
3334
const SITE_URL = "https://wp-codebox-runtime.invalid"
3435
const DATABASE_PATH = "/wordpress/wp-content/database/.ht.sqlite"
@@ -684,13 +685,12 @@ async function runBootProbe(phase: string, bucket: R2Bucket): Promise<Response>
684685
if (phase === "wordpress-archive" || phase === "sqlite-archive") {
685686
const archiveBytes = phase === "wordpress-archive"
686687
? (await readWordPressRuntimeArtifact(bucket)).byteLength
687-
: (await fetchArchive(SQLITE_INTEGRATION_ARCHIVE_URL, "sqlite-database-integration.zip")).size
688+
: (await readSqliteIntegrationArtifact(bucket)).size
688689
return probeResponse(phase, { archiveBytes })
689690
}
690691

691692
if (phase === "archives") {
692-
const wordpressBytes = await readWordPressRuntimeArtifact(bucket)
693-
const sqliteZip = await fetchArchive(SQLITE_INTEGRATION_ARCHIVE_URL, "sqlite-database-integration.zip")
693+
const [wordpressBytes, sqliteZip] = await Promise.all([readWordPressRuntimeArtifact(bucket), readSqliteIntegrationArtifact(bucket)])
694694
return probeResponse(phase, { wordpressArchiveBytes: wordpressBytes.byteLength, sqliteArchiveBytes: sqliteZip.size })
695695
}
696696

@@ -888,6 +888,8 @@ async function bootWordPressRuntime(
888888
shouldPatchCanonicalRuntimePoliciesAtInit = false,
889889
uploadFiles?: RuntimeFile[],
890890
): Promise<{ php: PHP; requestHandler: PHPRequestHandler; wordpressVersion: string }> {
891+
if (includeSqlite && !runtimeBucket) throw new Error("SQLite integration artifact requires WORDPRESS_STATE_BUCKET.")
892+
const sqliteIntegrationPluginZip = includeSqlite ? readSqliteIntegrationArtifact(runtimeBucket!) : undefined
891893
const requestHandler = await bootWordPressAndRequestHandler({
892894
createPhpRuntime,
893895
constants: {
@@ -934,7 +936,7 @@ async function bootWordPressRuntime(
934936
phpVersion: "8.5",
935937
siteUrl,
936938
wordPressZip: undefined,
937-
sqliteIntegrationPluginZip: includeSqlite ? fetchArchive(SQLITE_INTEGRATION_ARCHIVE_URL, "sqlite-database-integration.zip") : undefined,
939+
sqliteIntegrationPluginZip,
938940
wordpressInstallMode,
939941
})
940942
const php = await requestHandler.getPrimaryPhp()
@@ -1088,6 +1090,12 @@ async function readWordPressRuntimeArtifact(bucket: R2Bucket): Promise<Uint8Arra
10881090
return bytes
10891091
}
10901092

1093+
async function readSqliteIntegrationArtifact(bucket: R2Bucket): Promise<File> {
1094+
const manifest = sqliteIntegrationArtifactManifest as RuntimeArchiveArtifactManifest
1095+
const bytes = await readRuntimeArchiveArtifact(bucket, manifest)
1096+
return new File([Uint8Array.from(bytes).buffer], "sqlite-database-integration.zip", { type: "application/zip" })
1097+
}
1098+
10911099
async function serveWordPressUpload(request: Request, bucket: R2Bucket, coordinator: DurableObjectStub): Promise<Response | null> {
10921100
if (request.method !== "GET" && request.method !== "HEAD") return null
10931101
const url = new URL(request.url)
@@ -1220,12 +1228,6 @@ function createPhpRuntime() {
12201228
)
12211229
}
12221230

1223-
async function fetchArchive(url: string, name: string): Promise<File> {
1224-
const response = await fetch(url)
1225-
if (!response.ok) throw new Error(`Unable to fetch ${name}: ${response.status}.`)
1226-
return new File([await response.arrayBuffer()], name, { type: "application/zip" })
1227-
}
1228-
12291231
function instantiatePrecompiledWasm(module: WebAssembly.Module) {
12301232
return (imports: WebAssembly.Imports, receiveInstance: (instance: WebAssembly.Instance, wasmModule: WebAssembly.Module) => void) => receiveInstance(new WebAssembly.Instance(module, imports), module)
12311233
}

scripts/generate-cloudflare-wordpress-runtime-corpus.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,17 @@ import { decodeZip, encodeZip } from "@php-wasm/stream-compression"
55
import { isWordPressRuntimeFile, isWordPressStaticAsset } from "../packages/runtime-cloudflare/src/wordpress-runtime-corpus.js"
66
import { WORDPRESS_RUNTIME_ARTIFACT_SCHEMA, wordpressRuntimeArtifactKey, type WordPressRuntimeArtifactManifest } from "../packages/runtime-cloudflare/src/wordpress-runtime-artifact.js"
77
import { WORDPRESS_STATIC_ARTIFACT_SCHEMA, validateWordPressStaticArtifactManifest, wordpressStaticArtifactKey, type WordPressStaticArtifactManifest } from "../packages/runtime-cloudflare/src/wordpress-static-artifact.js"
8+
import { RUNTIME_ARCHIVE_ARTIFACT_SCHEMA, runtimeArchiveArtifactKey, validateRuntimeArchiveArtifactManifest, type RuntimeArchiveArtifactManifest } from "../packages/runtime-cloudflare/src/runtime-archive-artifact.js"
89

910
const sourceUrl = process.env.WORDPRESS_RUNTIME_ARCHIVE_URL ?? "https://downloads.wordpress.org/release/wordpress-7.0.2.zip"
1011
const sourceVersion = process.env.WORDPRESS_RUNTIME_VERSION ?? "7.0.2"
1112
const output = resolve(process.env.WORDPRESS_RUNTIME_ARTIFACT_OUTPUT ?? "artifacts/cloudflare-wordpress-runtime-corpus.zip")
1213
const manifestOutput = resolve("packages/runtime-cloudflare/assets/wordpress-runtime-artifact.json")
1314
const staticOutput = resolve(process.env.WORDPRESS_STATIC_ARTIFACT_OUTPUT ?? "artifacts/cloudflare-wordpress-static-corpus.bin")
1415
const staticManifestOutput = resolve("packages/runtime-cloudflare/assets/wordpress-static-artifact.json")
16+
const sqliteSourceUrl = "https://github.com/WordPress/sqlite-database-integration/releases/download/v2.2.23/plugin-sqlite-database-integration.zip"
17+
const sqliteOutput = resolve("artifacts/cloudflare-sqlite-database-integration.zip")
18+
const sqliteManifestOutput = resolve("packages/runtime-cloudflare/assets/sqlite-database-integration-artifact.json")
1519
const response = await fetch(sourceUrl)
1620
if (!response.ok || !response.body) throw new Error(`Unable to download WordPress archive: ${response.status}.`)
1721
const identity = response.headers.get("etag") ?? response.headers.get("last-modified") ?? undefined
@@ -59,9 +63,26 @@ const staticManifest: WordPressStaticArtifactManifest = {
5963
validateWordPressStaticArtifactManifest(staticManifest)
6064
await writeFile(staticOutput, staticBlob)
6165
await writeFile(staticManifestOutput, `${JSON.stringify(staticManifest, null, 2)}\n`)
66+
67+
const sqliteResponse = await fetch(sqliteSourceUrl)
68+
if (!sqliteResponse.ok) throw new Error(`Unable to download SQLite integration archive: ${sqliteResponse.status}.`)
69+
const sqliteArchive = new Uint8Array(await sqliteResponse.arrayBuffer())
70+
const sqliteSha256 = sha256Hex(sqliteArchive)
71+
const sqliteIdentity = sqliteResponse.headers.get("etag") ?? sqliteResponse.headers.get("last-modified") ?? undefined
72+
const sqliteManifest: RuntimeArchiveArtifactManifest = {
73+
schema: RUNTIME_ARCHIVE_ARTIFACT_SCHEMA,
74+
name: "sqlite-database-integration",
75+
key: runtimeArchiveArtifactKey("sqlite-database-integration", sqliteSha256),
76+
archive: { sha256: sqliteSha256, size: sqliteArchive.byteLength },
77+
source: { url: sqliteSourceUrl, version: "2.2.23", ...(sqliteIdentity ? { identity: sqliteIdentity } : {}) },
78+
}
79+
validateRuntimeArchiveArtifactManifest(sqliteManifest)
80+
await writeFile(sqliteOutput, sqliteArchive)
81+
await writeFile(sqliteManifestOutput, `${JSON.stringify(sqliteManifest, null, 2)}\n`)
6282
console.log(JSON.stringify({
6383
runtime: { key: manifest.key, bytes: archive.byteLength, files: manifest.files.length, sha256: archiveSha256 },
6484
static: { key: staticManifest.key, bytes: staticBlob.byteLength, files: staticManifest.files.length, sha256: staticSha256 },
85+
sqlite: { key: sqliteManifest.key, bytes: sqliteArchive.byteLength, sha256: sqliteSha256 },
6586
source: manifest.source,
6687
}))
6788

scripts/provision-cloudflare-wordpress-runtime-corpus.mjs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,15 @@ const staticManifest = JSON.parse(await readFile("packages/runtime-cloudflare/as
1717
const staticBlob = await readFile("artifacts/cloudflare-wordpress-static-corpus.bin")
1818
const staticActual = createHash("sha256").update(staticBlob).digest("hex")
1919
if (staticActual !== staticManifest.blob.sha256 || staticBlob.byteLength !== staticManifest.blob.size || staticManifest.key !== `runtime/wordpress-static/${staticActual}.bin`) throw new Error("Local WordPress static artifact does not match its content-addressed manifest.")
20+
const sqliteManifest = JSON.parse(await readFile("packages/runtime-cloudflare/assets/sqlite-database-integration-artifact.json", "utf8"))
21+
const sqliteArchive = await readFile("artifacts/cloudflare-sqlite-database-integration.zip")
22+
const sqliteActual = createHash("sha256").update(sqliteArchive).digest("hex")
23+
if (sqliteActual !== sqliteManifest.archive.sha256 || sqliteArchive.byteLength !== sqliteManifest.archive.size || sqliteManifest.key !== `runtime/archives/sqlite-database-integration/${sqliteActual}.zip`) throw new Error("Local SQLite integration artifact does not match its content-addressed manifest.")
2024

2125
for (const artifact of [
2226
{ key: manifest.key, size: manifest.archive.size, file: "artifacts/cloudflare-wordpress-runtime-corpus.zip" },
2327
{ key: staticManifest.key, size: staticManifest.blob.size, file: "artifacts/cloudflare-wordpress-static-corpus.bin" },
28+
{ key: sqliteManifest.key, size: sqliteManifest.archive.size, file: "artifacts/cloudflare-sqlite-database-integration.zip" },
2429
]) {
2530
const command = ["exec", "--", "wrangler", "r2", "object", "put", `wp-codebox-runtime-chubes/${artifact.key}`, "--file", artifact.file, local ? "--local" : "--remote"]
2631
if (persistTo) command.push("--persist-to", persistTo)

0 commit comments

Comments
 (0)