Skip to content

Commit 65fe008

Browse files
authored
Merge pull request #1873 from Automattic/fix/1838-cloudflare-serve-wordpress
Serve WordPress from the Cloudflare runtime
2 parents 3418cfa + e1ab12f commit 65fe008

6 files changed

Lines changed: 128 additions & 15 deletions

File tree

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,19 @@
11
declare module "@php-wasm/universal" {
2+
export interface PHPRequest {
3+
method?: string
4+
url: string
5+
headers?: Record<string, string>
6+
body?: string | Uint8Array
7+
}
8+
9+
export interface PHPResponseData {
10+
readonly headers: Record<string, string[]>
11+
readonly bytes: Uint8Array
12+
readonly errors: string
13+
readonly exitCode: number
14+
readonly httpStatusCode: number
15+
}
16+
217
export class PHP {
318
constructor(runtimeId: number)
419
run(request: { code: string }): Promise<{ bytes: Uint8Array; text: string }>
@@ -21,5 +36,6 @@ declare module "@php-wasm/universal" {
2136

2237
export class PHPRequestHandler {
2338
getPrimaryPhp(): Promise<PHP>
39+
request(request: PHPRequest): Promise<PHPResponseData>
2440
}
2541
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
export type WorkerRequestRoute =
2+
| { kind: "wordpress" }
3+
| { kind: "health" }
4+
| { kind: "r2-state" }
5+
| { kind: "r2-mutate" }
6+
| { kind: "probe"; phase: string }
7+
8+
export function routeWorkerRequest(request: Request): WorkerRequestRoute {
9+
const phase = new URL(request.url).searchParams.get("phase")
10+
if (phase === null) return { kind: "wordpress" }
11+
if (phase === "health") return { kind: "health" }
12+
if (phase === "r2-state") return { kind: "r2-state" }
13+
if (phase === "r2-mutate") return { kind: "r2-mutate" }
14+
return { kind: "probe", phase }
15+
}
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { PHPRequest, PHPResponseData } from "@php-wasm/universal"
2+
3+
export async function toPHPRequest(request: Request): Promise<PHPRequest> {
4+
const url = new URL(request.url)
5+
const headers: Record<string, string> = {}
6+
request.headers.forEach((value, name) => { headers[name] = value })
7+
const body = request.method === "GET" || request.method === "HEAD"
8+
? undefined
9+
: new Uint8Array(await request.arrayBuffer())
10+
11+
return {
12+
method: request.method as PHPRequest["method"],
13+
url: `${url.pathname}${url.search}`,
14+
headers,
15+
...(body ? { body } : {}),
16+
}
17+
}
18+
19+
export function toFetchResponse(request: Request, response: PHPResponseData): Response {
20+
const headers = new Headers()
21+
for (const [name, values] of Object.entries(response.headers)) {
22+
for (const value of values) headers.append(name, value)
23+
}
24+
const body = request.method === "HEAD" || [204, 205, 304].includes(response.httpStatusCode)
25+
? null
26+
: response.bytes as unknown as BodyInit
27+
return new Response(body, { status: response.httpStatusCode, headers })
28+
}

packages/runtime-cloudflare/src/worker.ts

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
1-
import { loadPHPRuntime, PHP } from "@php-wasm/universal"
1+
import { loadPHPRuntime, PHP, type PHPRequestHandler } from "@php-wasm/universal"
22
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.
66
import { dependenciesTotalSize, init } from "../../../node_modules/@php-wasm/web-8-5/asyncify/php_8_5.js"
77
import phpWasmModule from "../../../node_modules/@php-wasm/web-8-5/asyncify/8_5_8/php_8_5.wasm"
88
import { CLOUDFLARE_RUNTIME_HEALTH_MARKER, CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, cloudflareRuntimeHealthResponse } from "./health-envelope.js"
9+
import { routeWorkerRequest } from "./request-routing.js"
10+
import { toFetchResponse, toPHPRequest } from "./request-translation.js"
911
import markdownDatabaseIntegrationRuntime from "../assets/markdown-database-integration-runtime.zip"
1012
import markdownPrimaryBootstrapIndex from "../assets/markdown-primary-bootstrap-index.sqlite"
1113
import wordpressInstallSeed from "../assets/wordpress-install-seed.sqlite"
@@ -60,7 +62,7 @@ if (empty($option_rows)) {
6062
}
6163
$changes = $runtime->flush();
6264
echo json_encode(['revisionValue' => $value, 'previousPostFound' => !empty($previous_rows), 'postId' => $post_id, 'wordpressVersion' => $wp_version, 'canonicalChanges' => $changes]);`
63-
let bootPromise: Promise<{ php: PHP; wordpressVersion: string }> | undefined
65+
let bootPromise: Promise<{ php: PHP; requestHandler: PHPRequestHandler; wordpressVersion: string }> | undefined
6466

6567
interface Env {
6668
WORDPRESS_STATE: DurableObjectNamespace
@@ -69,25 +71,29 @@ interface Env {
6971

7072
export default {
7173
async fetch(request: Request, env: Env): Promise<Response> {
72-
const phase = new URL(request.url).searchParams.get("phase")
73-
if (phase === "r2-state" || phase === "r2-mutate") {
74-
const expectedMethod = phase === "r2-mutate" ? "POST" : "GET"
74+
const route = routeWorkerRequest(request)
75+
if (route.kind === "r2-state" || route.kind === "r2-mutate") {
76+
const expectedMethod = route.kind === "r2-mutate" ? "POST" : "GET"
7577
if (request.method !== expectedMethod) {
76-
return new Response(`WordPress state ${phase === "r2-mutate" ? "mutation" : "read"} requires ${expectedMethod}.`, { status: 405 })
78+
return new Response(`WordPress state ${route.kind === "r2-mutate" ? "mutation" : "read"} requires ${expectedMethod}.`, { status: 405 })
7779
}
7880
const coordinator = env.WORDPRESS_STATE.getByName("default")
79-
return phase === "r2-state"
81+
return route.kind === "r2-state"
8082
? coordinator.fetch(new Request("https://coordinator/state"))
8183
: runSerializedMarkdownMutation(env, coordinator)
8284
}
83-
if (phase) return runBootProbe(phase)
85+
if (route.kind === "probe") return runBootProbe(route.phase)
8486

8587
const runtime = await (bootPromise ??= bootWordPressRuntime(
8688
"do-not-attempt-installing",
8789
true,
88-
true,
90+
false,
8991
new Uint8Array(wordpressInstallSeed),
92+
undefined,
93+
undefined,
94+
new URL(request.url).origin,
9095
))
96+
if (route.kind === "wordpress") return toFetchResponse(request, await runtime.requestHandler.request(await toPHPRequest(request)))
9197
const phpVersion = (await runtime.php.run({ code: "<?php echo PHP_VERSION;" })).text.trim()
9298
return cloudflareRuntimeHealthResponse({
9399
schema: CLOUDFLARE_RUNTIME_HEALTH_SCHEMA,
@@ -517,7 +523,8 @@ async function bootWordPressRuntime(
517523
databaseSeed?: Uint8Array,
518524
markdownFiles?: RuntimeFile[],
519525
markdownIndexSeed?: Uint8Array,
520-
): Promise<{ php: PHP; wordpressVersion: string }> {
526+
siteUrl = SITE_URL,
527+
): Promise<{ php: PHP; requestHandler: PHPRequestHandler; wordpressVersion: string }> {
521528
const requestHandler = await bootWordPressAndRequestHandler({
522529
createPhpRuntime,
523530
constants: {
@@ -550,15 +557,15 @@ async function bootWordPressRuntime(
550557
} : undefined,
551558
maxPhpInstances: 1,
552559
phpVersion: "8.5",
553-
siteUrl: SITE_URL,
560+
siteUrl,
554561
wordPressZip: streamWordPressFiles ? undefined : fetchArchive(WORDPRESS_ARCHIVE_URL, "wordpress.zip"),
555562
sqliteIntegrationPluginZip: includeSqlite ? fetchArchive(SQLITE_INTEGRATION_ARCHIVE_URL, "sqlite-database-integration.zip") : undefined,
556563
wordpressInstallMode,
557564
})
558565
const php = await requestHandler.getPrimaryPhp()
559566
const wordpressVersion = (await php.run({ code: "<?php require '/wordpress/wp-includes/version.php'; echo $wp_version;" })).text.trim()
560567
if (!wordpressVersion) throw new Error("WordPress boot completed without a detected version.")
561-
return { php, wordpressVersion }
568+
return { php, requestHandler, wordpressVersion }
562569
}
563570

564571
function initialMarkdownFiles(): RuntimeFile[] {

scripts/cloudflare-local-gate.mjs

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,9 +13,10 @@ child.stderr.on("data", (chunk) => { output += chunk })
1313

1414
try {
1515
await waitForServer()
16+
await assertWordPressPage(url, "front page")
1617
await assertHealthResponse()
17-
await assertHealthResponse()
18-
console.log("Cloudflare local runtime gate passed: two HTTP 200 health envelopes returned.")
18+
await assertWordPressPage(`${url}wp-admin/`, "WordPress admin route")
19+
console.log("Cloudflare local runtime gate passed: front page, explicit health envelope, and admin route returned.")
1920
} finally {
2021
child.kill("SIGTERM")
2122
await new Promise((resolve) => child.once("exit", resolve))
@@ -32,10 +33,18 @@ async function waitForServer() {
3233
}
3334

3435
async function assertHealthResponse() {
35-
const response = await fetch(url)
36+
const response = await fetch(`${url}?phase=health`)
3637
if (response.status !== 200) throw new Error(`Expected HTTP 200, received ${response.status}: ${await response.text()}`)
3738
const body = await response.json()
3839
if (body.schema !== "wp-codebox/cloudflare-runtime-health/v1" || body.marker !== "wp-codebox-cloudflare-runtime-health" || body.phpVersion !== "8.5.8" || typeof body.wordpressVersion !== "string" || body.execution?.schema !== "wp-codebox/runtime-command-result/v1" || body.execution?.status !== "ok") {
3940
throw new Error(`Unexpected Cloudflare runtime health envelope: ${JSON.stringify(body)}`)
4041
}
4142
}
43+
44+
async function assertWordPressPage(target, label) {
45+
const response = await fetch(target)
46+
const body = await response.text()
47+
if (response.status !== 200 || !response.headers.get("content-type")?.includes("text/html") || !/<html[\s>]/i.test(body)) {
48+
throw new Error(`Expected an HTML ${label}, received ${response.status}: ${body}`)
49+
}
50+
}

tests/cloudflare-runtime.test.ts

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import test from "node:test"
44
import { decodeZip } from "@php-wasm/stream-compression"
55
import { RUNTIME_COMMAND_RESULT_SCHEMA } from "../packages/runtime-core/src/runtime-contracts.js"
66
import { CLOUDFLARE_RUNTIME_HEALTH_MARKER, CLOUDFLARE_RUNTIME_HEALTH_SCHEMA, cloudflareRuntimeHealthResponse } from "../packages/runtime-cloudflare/src/health-envelope.js"
7+
import { routeWorkerRequest } from "../packages/runtime-cloudflare/src/request-routing.js"
8+
import { toFetchResponse, toPHPRequest } from "../packages/runtime-cloudflare/src/request-translation.js"
79

810
test("Cloudflare health response preserves the Codebox execution envelope", async () => {
911
const health = {
@@ -25,6 +27,42 @@ test("Cloudflare health response preserves the Codebox execution envelope", asyn
2527
assert.deepEqual(payload.execution.json, health)
2628
})
2729

30+
test("Cloudflare routing reserves phases while the phase-less route serves WordPress", () => {
31+
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/")), { kind: "wordpress" })
32+
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=health")), { kind: "health" })
33+
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=r2-state")), { kind: "r2-state" })
34+
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=r2-mutate")), { kind: "r2-mutate" })
35+
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=seeded-wordpress")), { kind: "probe", phase: "seeded-wordpress" })
36+
})
37+
38+
test("Cloudflare translates Fetch requests and PHP responses without losing browser data", async () => {
39+
const headers = new Headers({ "content-type": "application/octet-stream", "x-request-id": "first" })
40+
headers.append("x-request-id", "second")
41+
const request = new Request("https://worker.example/wp-admin/admin-ajax.php?action=save", {
42+
method: "POST",
43+
headers,
44+
body: new Uint8Array([0, 1, 255]),
45+
})
46+
const phpRequest = await toPHPRequest(request)
47+
48+
assert.equal(phpRequest.method, "POST")
49+
assert.equal(phpRequest.url, "/wp-admin/admin-ajax.php?action=save")
50+
assert.equal(phpRequest.headers?.["x-request-id"], "first, second")
51+
assert.deepEqual(Array.from(phpRequest.body as Uint8Array), [0, 1, 255])
52+
53+
const response = toFetchResponse(request, {
54+
httpStatusCode: 201,
55+
headers: { "content-type": ["application/octet-stream"], "set-cookie": ["first=1; Path=/", "second=2; Path=/"] },
56+
bytes: new Uint8Array([255, 1, 0]),
57+
errors: "",
58+
exitCode: 0,
59+
})
60+
const responseHeaders = response.headers as Headers & { getSetCookie?: () => string[] }
61+
assert.equal(response.status, 201)
62+
assert.deepEqual(Array.from(new Uint8Array(await response.arrayBuffer())), [255, 1, 0])
63+
assert.deepEqual(responseHeaders.getSetCookie?.() ?? [response.headers.get("set-cookie")], ["first=1; Path=/", "second=2; Path=/"])
64+
})
65+
2866
test("Cloudflare runtime declares the paid-plan WordPress boot CPU budget", async () => {
2967
const config = JSON.parse(await readFile(new URL("../packages/runtime-cloudflare/wrangler.jsonc", import.meta.url), "utf8")) as { limits?: { cpu_ms?: number } }
3068
assert.equal(config.limits?.cpu_ms, 300_000)

0 commit comments

Comments
 (0)