11import { loadPHPRuntime , PHP } from "@php-wasm/universal"
2- import { decodeRemoteZip } from "@php-wasm/stream-compression"
2+ import { decodeRemoteZip , decodeZip } from "@php-wasm/stream-compression"
33import { 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"
1111const PHP_VERSION = "8.5.8"
1212const WORDPRESS_ARCHIVE_URL = "https://wordpress.org/latest.zip"
1313const 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 } `
1416const SITE_URL = "https://wp-codebox-runtime.invalid"
1517const 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"
1823let bootPromise : Promise < { php : PHP ; wordpressVersion : string } > | undefined
1924
2025interface 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+
6283export 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 ( / ^ m d i - / , "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+
296508async function materializeWordPressServerFiles ( php : PHP ) : Promise < { materializedFiles : number ; materializedBytes : number } > {
297509 const decoder = new TextDecoder ( )
298510 let materializedFiles = 0
0 commit comments