Skip to content

Commit b8ea986

Browse files
committed
Probe MDI primary R2 persistence
1 parent 6d2179e commit b8ea986

2 files changed

Lines changed: 254 additions & 40 deletions

File tree

packages/runtime-cloudflare/src/php-wasm-universal.d.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ declare module "@php-wasm/universal" {
22
export class PHP {
33
constructor(runtimeId: number)
44
run(request: { code: string }): Promise<{ bytes: Uint8Array; text: string }>
5+
isDir(path: string): boolean
6+
listFiles(path: string): string[]
57
mkdir(path: string): void
68
readFileAsBuffer(path: string): Uint8Array
79
writeFile(path: string, data: Uint8Array): void

packages/runtime-cloudflare/src/worker.ts

Lines changed: 252 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { loadPHPRuntime, PHP } from "@php-wasm/universal"
2-
import { decodeRemoteZip } from "@php-wasm/stream-compression"
2+
import { decodeRemoteZip, decodeZip } from "@php-wasm/stream-compression"
33
import { bootWordPressAndRequestHandler, type WordPressInstallMode } from "@wp-playground/wordpress"
44
// The PHP-WASM package publishes this Emscripten loader without TypeScript declarations.
55
// @ts-expect-error The adjacent Wasm declaration covers the compiled binary import.
@@ -11,10 +11,15 @@ import wordpressInstallSeed from "../assets/wordpress-install-seed.sqlite"
1111
const PHP_VERSION = "8.5.8"
1212
const WORDPRESS_ARCHIVE_URL = "https://wordpress.org/latest.zip"
1313
const SQLITE_INTEGRATION_ARCHIVE_URL = "https://github.com/WordPress/sqlite-database-integration/releases/download/v2.2.23/plugin-sqlite-database-integration.zip"
14+
const MARKDOWN_DATABASE_INTEGRATION_REVISION = "af451895a9429895e3ad4d9b4e073bfd88873745"
15+
const MARKDOWN_DATABASE_INTEGRATION_ARCHIVE_URL = `https://codeload.github.com/Automattic/markdown-database-integration/zip/${MARKDOWN_DATABASE_INTEGRATION_REVISION}`
1416
const SITE_URL = "https://wp-codebox-runtime.invalid"
1517
const DATABASE_PATH = "/wordpress/wp-content/database/.ht.sqlite"
16-
const R2_DATABASE_POINTER_KEY = "sites/default/database/current.json"
17-
const R2_DATABASE_REVISION_PREFIX = "sites/default/database/revisions"
18+
const MARKDOWN_ROOT = "/wordpress/wp-content/markdown"
19+
const MARKDOWN_INDEX_PATH = "/tmp/markdown-index.sqlite"
20+
const R2_MARKDOWN_POINTER_KEY = "sites/default/markdown/current.json"
21+
const R2_MARKDOWN_REVISION_PREFIX = "sites/default/markdown/revisions"
22+
const R2_MARKDOWN_OBJECT_PREFIX = "sites/default/markdown/objects"
1823
let bootPromise: Promise<{ php: PHP; wordpressVersion: string }> | undefined
1924

2025
interface Env {
@@ -53,12 +58,28 @@ export default {
5358
},
5459
}
5560

56-
interface DatabasePointer {
61+
interface MarkdownPointer {
5762
revision: string
58-
databaseKey: string
63+
manifestKey: string
5964
persistedAt: string
6065
}
6166

67+
interface MarkdownManifestFile {
68+
path: string
69+
objectKey: string
70+
sha256: string
71+
size: number
72+
}
73+
74+
interface MarkdownManifest extends MarkdownPointer {
75+
files: MarkdownManifestFile[]
76+
}
77+
78+
interface RuntimeFile {
79+
path: string
80+
bytes: Uint8Array
81+
}
82+
6283
export class WordPressStateCoordinator implements DurableObject {
6384
private tail: Promise<void> = Promise.resolve()
6485

@@ -68,7 +89,13 @@ export class WordPressStateCoordinator implements DurableObject {
6889
) {}
6990

7091
fetch(request: Request): Promise<Response> {
71-
const response = this.tail.then(() => this.handleRequest(request))
92+
const response = this.tail
93+
.then(() => this.handleRequest(request))
94+
.catch((error: unknown) => Response.json({
95+
schema: "wp-codebox/cloudflare-wordpress-state-error/v1",
96+
message: error instanceof Error ? error.message : String(error),
97+
stack: error instanceof Error ? error.stack : undefined,
98+
}, { status: 500 }))
7299
this.tail = response.then(() => undefined, () => undefined)
73100
return response
74101
}
@@ -85,50 +112,69 @@ export class WordPressStateCoordinator implements DurableObject {
85112
if (request.method !== "POST") return new Response("Method not allowed.", { status: 405 })
86113

87114
const pointer = await this.readPointer()
88-
const persistedDatabase = pointer ? await this.env.WORDPRESS_STATE_BUCKET.get(pointer.databaseKey) : null
89-
if (pointer && !persistedDatabase) {
90-
throw new Error(`R2 database revision is missing: ${pointer.databaseKey}`)
91-
}
92-
93-
const database = persistedDatabase
94-
? new Uint8Array(await persistedDatabase.arrayBuffer())
95-
: new Uint8Array(wordpressInstallSeed)
96-
const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, database)
115+
const markdownFiles = pointer ? await this.readMarkdownRevision(pointer) : initialMarkdownFiles()
116+
const runtime = await bootMarkdownWordPressRuntime(markdownFiles)
97117
try {
98118
const mutationOutput = (await runtime.php.run({
99-
code: "<?php require '/wordpress/wp-load.php'; $value = (int) get_option('wp_codebox_r2_revision', 0) + 1; update_option('wp_codebox_r2_revision', $value); echo json_encode(['revisionValue' => $value, 'wordpressVersion' => get_bloginfo('version')]);",
119+
code: "<?php require '/wordpress/wp-load.php'; $current = (int) get_option('wp_codebox_mdi_revision', 0); $previous_found = 0 === $current || null !== get_page_by_path('cloudflare-r2-proof-' . $current, OBJECT, 'post'); $value = $current + 1; $post_id = wp_insert_post(['post_title' => 'Cloudflare R2 Proof ' . $value, 'post_name' => 'cloudflare-r2-proof-' . $value, 'post_content' => 'Persisted by MDI primary mode in R2 revision ' . $value . '.', 'post_status' => 'publish', 'post_type' => 'post'], true); if (is_wp_error($post_id)) { throw new Exception($post_id->get_error_message()); } update_option('wp_codebox_mdi_revision', $value); echo json_encode(['revisionValue' => $value, 'previousPostFound' => $previous_found, 'postId' => $post_id, 'wordpressVersion' => get_bloginfo('version')]);",
100120
})).text.trim()
101-
const mutation = JSON.parse(mutationOutput) as { revisionValue: number; wordpressVersion: string }
102-
const revision = crypto.randomUUID()
103-
const databaseKey = `${R2_DATABASE_REVISION_PREFIX}/${revision}.sqlite`
104-
const persistedAt = new Date().toISOString()
105-
const databaseBytes = runtime.php.readFileAsBuffer(DATABASE_PATH)
106-
await this.env.WORDPRESS_STATE_BUCKET.put(databaseKey, databaseBytes, {
107-
customMetadata: {
108-
persistedAt,
109-
revisionValue: String(mutation.revisionValue),
110-
wordpressVersion: mutation.wordpressVersion,
111-
},
112-
})
113-
114-
const nextPointer: DatabasePointer = { revision, databaseKey, persistedAt }
115-
await this.env.WORDPRESS_STATE_BUCKET.put(R2_DATABASE_POINTER_KEY, JSON.stringify(nextPointer), {
116-
httpMetadata: { contentType: "application/json" },
117-
})
121+
const mutation = JSON.parse(mutationOutput) as { revisionValue: number; previousPostFound: boolean; postId: number; wordpressVersion: string }
122+
const canonicalFiles = collectRuntimeFiles(runtime.php, MARKDOWN_ROOT)
123+
const nextPointer = await this.persistMarkdownRevision(canonicalFiles)
118124
return Response.json({
119125
schema: "wp-codebox/cloudflare-wordpress-mutation/v1",
120-
source: pointer ? "r2-revision" : "packaged-seed",
126+
source: pointer ? "r2-markdown-revision" : "packaged-markdown-seed",
121127
...mutation,
128+
canonicalFiles: canonicalFiles.length,
129+
sqlitePersisted: false,
122130
pointer: nextPointer,
123131
})
124132
} finally {
125133
runtime.php.exit()
126134
}
127135
}
128136

129-
private async readPointer(): Promise<DatabasePointer | null> {
130-
const object = await this.env.WORDPRESS_STATE_BUCKET.get(R2_DATABASE_POINTER_KEY)
131-
return object ? object.json<DatabasePointer>() : null
137+
private async readPointer(): Promise<MarkdownPointer | null> {
138+
const object = await this.env.WORDPRESS_STATE_BUCKET.get(R2_MARKDOWN_POINTER_KEY)
139+
return object ? object.json<MarkdownPointer>() : null
140+
}
141+
142+
private async readMarkdownRevision(pointer: MarkdownPointer): Promise<RuntimeFile[]> {
143+
const manifestObject = await this.env.WORDPRESS_STATE_BUCKET.get(pointer.manifestKey)
144+
if (!manifestObject) throw new Error(`R2 Markdown manifest is missing: ${pointer.manifestKey}`)
145+
const manifest = await manifestObject.json<MarkdownManifest>()
146+
const files: RuntimeFile[] = []
147+
for (const file of manifest.files) {
148+
const object = await this.env.WORDPRESS_STATE_BUCKET.get(file.objectKey)
149+
if (!object) throw new Error(`R2 Markdown object is missing: ${file.objectKey}`)
150+
files.push({ path: file.path, bytes: new Uint8Array(await object.arrayBuffer()) })
151+
}
152+
return files
153+
}
154+
155+
private async persistMarkdownRevision(files: RuntimeFile[]): Promise<MarkdownPointer> {
156+
const manifestFiles: MarkdownManifestFile[] = []
157+
for (const file of files) {
158+
const sha256 = await sha256Hex(file.bytes)
159+
const objectKey = `${R2_MARKDOWN_OBJECT_PREFIX}/${sha256}`
160+
if (!await this.env.WORDPRESS_STATE_BUCKET.head(objectKey)) {
161+
await this.env.WORDPRESS_STATE_BUCKET.put(objectKey, file.bytes)
162+
}
163+
manifestFiles.push({ path: file.path, objectKey, sha256, size: file.bytes.byteLength })
164+
}
165+
166+
const revision = crypto.randomUUID()
167+
const manifestKey = `${R2_MARKDOWN_REVISION_PREFIX}/${revision}.json`
168+
const persistedAt = new Date().toISOString()
169+
const pointer: MarkdownPointer = { revision, manifestKey, persistedAt }
170+
const manifest: MarkdownManifest = { ...pointer, files: manifestFiles }
171+
await this.env.WORDPRESS_STATE_BUCKET.put(manifestKey, JSON.stringify(manifest), {
172+
httpMetadata: { contentType: "application/json" },
173+
})
174+
await this.env.WORDPRESS_STATE_BUCKET.put(R2_MARKDOWN_POINTER_KEY, JSON.stringify(pointer), {
175+
httpMetadata: { contentType: "application/json" },
176+
})
177+
return pointer
132178
}
133179
}
134180

@@ -181,6 +227,72 @@ async function runBootProbe(phase: string): Promise<Response> {
181227
}
182228
}
183229

230+
if (phase === "mdi-files") {
231+
const php = new PHP(await createPhpRuntime())
232+
try {
233+
const wordpress = await materializeWordPressServerFiles(php)
234+
await materializeMarkdownDatabaseIntegration(php)
235+
materializeRuntimeFiles(php, MARKDOWN_ROOT, initialMarkdownFiles())
236+
const evidence = (await php.run({
237+
code: "<?php echo json_encode(['dropin' => file_exists('/wordpress/wp-content/db.php'), 'plugin' => file_exists('/wordpress/wp-content/plugins/markdown-database-integration/markdown-database-integration.php'), 'siteurl' => file_exists('/wordpress/wp-content/markdown/_options/siteurl.json')]);",
238+
})).text.trim()
239+
return probeResponse(phase, { ...wordpress, ...JSON.parse(evidence) as Record<string, string> })
240+
} finally {
241+
php.exit()
242+
}
243+
}
244+
245+
if (phase === "mdi-shortinit") {
246+
const runtime = await bootMarkdownWordPressRuntime(initialMarkdownFiles())
247+
try {
248+
const evidence = (await runtime.php.run({
249+
code: "<?php define('SHORTINIT', true); require '/wordpress/wp-load.php'; echo json_encode(['wordpressVersion' => $wp_version, 'markdownDropin' => defined('MARKDOWN_DB_DROPIN'), 'markdownMode' => defined('MARKDOWN_DB_MODE') ? MARKDOWN_DB_MODE : '']);",
250+
})).text.trim()
251+
return probeResponse(phase, JSON.parse(evidence) as Record<string, string>)
252+
} finally {
253+
runtime.php.exit()
254+
}
255+
}
256+
257+
if (phase === "mdi-index-artifact") {
258+
const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles())
259+
try {
260+
await runtime.php.run({ code: "<?php define('SHORTINIT', true); require '/wordpress/wp-load.php';" })
261+
return new Response(Uint8Array.from(runtime.php.readFileAsBuffer(MARKDOWN_INDEX_PATH)).buffer, {
262+
headers: { "Content-Type": "application/vnd.sqlite3" },
263+
})
264+
} finally {
265+
runtime.php.exit()
266+
}
267+
}
268+
269+
if (phase === "mdi-wordpress" || phase === "mdi-option" || phase === "mdi-insert") {
270+
const runtime = await bootMarkdownWordPressRuntime(initialMarkdownFiles())
271+
try {
272+
const operation = phase === "mdi-option"
273+
? "$updated = update_option('wp_codebox_mdi_probe', 1); $result = ['updated' => $updated];"
274+
: phase === "mdi-insert"
275+
? "$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];"
276+
: "$result = [];"
277+
const evidence = (await runtime.php.run({
278+
code: `<?php require '/wordpress/wp-load.php'; ${operation} echo json_encode(array_merge(['wordpressVersion' => get_bloginfo('version')], $result));`,
279+
})).text.trim()
280+
return probeResponse(phase, JSON.parse(evidence) as Record<string, string>)
281+
} finally {
282+
runtime.php.exit()
283+
}
284+
}
285+
286+
if (["mdi-includes", "mdi-embed", "mdi-textdomain", "mdi-ai-client", "mdi-plugin-constants", "mdi-muplugins", "mdi-plugins", "mdi-theme", "mdi-site-health-class", "mdi-site-health", "mdi-current-user", "mdi-init", "mdi-wp-loaded"].includes(phase)) {
287+
const runtime = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, initialMarkdownFiles())
288+
try {
289+
const evidence = (await runtime.php.run({ code: wordpressProbeCode(phase) })).text.trim()
290+
return probeResponse(phase, JSON.parse(evidence) as Record<string, string>)
291+
} finally {
292+
runtime.php.exit()
293+
}
294+
}
295+
184296
if (phase?.startsWith("seeded-")) {
185297
const runtime = await bootWordPressRuntime(
186298
"do-not-attempt-installing",
@@ -234,6 +346,10 @@ function wordpressProbeCode(phase: string): string {
234346

235347
const stops: Record<string, { needle: string; after?: boolean }> = {
236348
"seeded-includes": { needle: "/**\n * @since 3.3.0" },
349+
"seeded-embed": { needle: "/**\n * WordPress Textdomain Registry object." },
350+
"seeded-textdomain": { needle: "// WordPress AI Client initialization." },
351+
"seeded-ai-client": { needle: "// Load multisite-specific files." },
352+
"seeded-plugin-constants": { needle: "// Load must-use plugins." },
237353
"seeded-muplugins": { needle: "if ( is_multisite() ) {\n\tms_cookie_constants();" },
238354
"seeded-plugins": { needle: "// Define constants which affect functionality if not already defined." },
239355
"seeded-theme": { needle: "// Create an instance of WP_Site_Health so that Cron events may fire." },
@@ -243,7 +359,7 @@ function wordpressProbeCode(phase: string): string {
243359
"seeded-init": { needle: "// Check site status." },
244360
"seeded-wp-loaded": { needle: "do_action( 'wp_loaded' );", after: true },
245361
}
246-
const stop = stops[phase]
362+
const stop = stops[phase.replace(/^mdi-/, "seeded-")]
247363
if (!stop) throw new Error(`Unknown seeded WordPress probe phase: ${phase}`)
248364

249365
return `<?php
@@ -264,17 +380,32 @@ async function bootWordPressRuntime(
264380
includeSqlite = true,
265381
streamWordPressFiles = false,
266382
databaseSeed?: Uint8Array,
383+
markdownFiles?: RuntimeFile[],
384+
markdownIndexSeed?: Uint8Array,
267385
): Promise<{ php: PHP; wordpressVersion: string }> {
268386
const requestHandler = await bootWordPressAndRequestHandler({
269387
createPhpRuntime,
270388
constants: {
271389
AUTOMATIC_UPDATER_DISABLED: true,
272390
DISABLE_WP_CRON: true,
391+
...(markdownFiles ? {
392+
MARKDOWN_DB_CONTENT_DIR: MARKDOWN_ROOT,
393+
MARKDOWN_DB_INDEX_PATH: MARKDOWN_INDEX_PATH,
394+
MARKDOWN_DB_MODE: "primary",
395+
MARKDOWN_DB_STATE_DIR: MARKDOWN_ROOT,
396+
} : {}),
273397
WP_HTTP_BLOCK_EXTERNAL: true,
274398
},
275399
dataSqlPath: DATABASE_PATH,
276-
hooks: streamWordPressFiles || databaseSeed ? {
277-
beforeWordPressFiles: streamWordPressFiles ? materializeWordPressServerFiles : undefined,
400+
hooks: streamWordPressFiles || databaseSeed || markdownFiles ? {
401+
beforeWordPressFiles: streamWordPressFiles || markdownFiles ? async (php: PHP) => {
402+
if (streamWordPressFiles) await materializeWordPressServerFiles(php)
403+
if (markdownFiles) {
404+
await materializeMarkdownDatabaseIntegration(php)
405+
materializeRuntimeFiles(php, MARKDOWN_ROOT, markdownFiles)
406+
if (markdownIndexSeed) php.writeFile(MARKDOWN_INDEX_PATH, markdownIndexSeed)
407+
}
408+
} : undefined,
278409
beforeDatabaseSetup: databaseSeed ? (php: PHP) => {
279410
php.mkdir("/wordpress/wp-content/database")
280411
php.writeFile(DATABASE_PATH, databaseSeed)
@@ -293,6 +424,87 @@ async function bootWordPressRuntime(
293424
return { php, wordpressVersion }
294425
}
295426

427+
async function bootMarkdownWordPressRuntime(markdownFiles: RuntimeFile[]): Promise<{ php: PHP; wordpressVersion: string }> {
428+
const indexBuilder = await bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, markdownFiles)
429+
let markdownIndex: Uint8Array
430+
try {
431+
await indexBuilder.php.run({
432+
code: "<?php define('SHORTINIT', true); require '/wordpress/wp-load.php';",
433+
})
434+
markdownIndex = Uint8Array.from(indexBuilder.php.readFileAsBuffer(MARKDOWN_INDEX_PATH))
435+
} finally {
436+
indexBuilder.php.exit()
437+
}
438+
return bootWordPressRuntime("do-not-attempt-installing", true, true, undefined, markdownFiles, markdownIndex)
439+
}
440+
441+
function initialMarkdownFiles(): RuntimeFile[] {
442+
const options = [
443+
{ option_id: 1, option_name: "siteurl", option_value: SITE_URL, autoload: "on" },
444+
{ option_id: 2, option_name: "home", option_value: SITE_URL, autoload: "on" },
445+
{ option_id: 3, option_name: "blogname", option_value: "WP Codebox Cloudflare Runtime", autoload: "on" },
446+
]
447+
return options.map((option) => ({
448+
path: `_options/${option.option_name}.json`,
449+
bytes: new TextEncoder().encode(JSON.stringify(option, null, 2)),
450+
}))
451+
}
452+
453+
function materializeRuntimeFiles(php: PHP, root: string, files: RuntimeFile[]): void {
454+
for (const file of files) {
455+
const destination = `${root}/${file.path}`
456+
php.mkdir(destination.slice(0, destination.lastIndexOf("/")))
457+
php.writeFile(destination, file.bytes)
458+
}
459+
}
460+
461+
function collectRuntimeFiles(php: PHP, root: string): RuntimeFile[] {
462+
const files: RuntimeFile[] = []
463+
const visit = (directory: string): void => {
464+
for (const name of php.listFiles(directory)) {
465+
if (name === "." || name === "..") continue
466+
const path = `${directory}/${name}`
467+
if (php.isDir(path)) {
468+
visit(path)
469+
} else if (!name.includes(".tmp.") && !name.startsWith("markdown-index.sqlite")) {
470+
files.push({ path: path.slice(root.length + 1), bytes: php.readFileAsBuffer(path) })
471+
}
472+
}
473+
}
474+
visit(root)
475+
return files.sort((left, right) => left.path.localeCompare(right.path))
476+
}
477+
478+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
479+
const digest = await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes).buffer)
480+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
481+
}
482+
483+
async function materializeMarkdownDatabaseIntegration(php: PHP): Promise<void> {
484+
const response = await fetch(MARKDOWN_DATABASE_INTEGRATION_ARCHIVE_URL)
485+
if (!response.ok || !response.body) throw new Error(`Unable to fetch Markdown Database Integration: ${response.status}.`)
486+
const stream = decodeZip(response.body)
487+
const reader = stream.getReader()
488+
while (true) {
489+
const { done, value: entry } = await reader.read()
490+
if (done) break
491+
const relative = archiveRelativePath(entry.name)
492+
if (relative !== "db.php"
493+
&& relative !== "markdown-database-integration.php"
494+
&& !(relative.startsWith("inc/") && relative.endsWith(".php"))) continue
495+
const bytes = new Uint8Array(await entry.arrayBuffer())
496+
const destination = `/wordpress/wp-content/plugins/markdown-database-integration/${relative}`
497+
php.mkdir(destination.slice(0, destination.lastIndexOf("/")))
498+
php.writeFile(destination, bytes)
499+
if (relative === "db.php") php.writeFile("/wordpress/wp-content/db.php", bytes)
500+
}
501+
}
502+
503+
function archiveRelativePath(path: string): string {
504+
const separator = path.indexOf("/")
505+
return separator === -1 ? "" : path.slice(separator + 1)
506+
}
507+
296508
async function materializeWordPressServerFiles(php: PHP): Promise<{ materializedFiles: number; materializedBytes: number }> {
297509
const decoder = new TextDecoder()
298510
let materializedFiles = 0

0 commit comments

Comments
 (0)