Skip to content

Commit 55fba5c

Browse files
committed
Add canonical Cloudflare state restore
1 parent 1e9e86e commit 55fba5c

3 files changed

Lines changed: 47 additions & 0 deletions

File tree

packages/runtime-cloudflare/src/request-routing.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export type WorkerRequestRoute =
44
| { kind: "r2-state" }
55
| { kind: "r2-mutate" }
66
| { kind: "operator-reset" }
7+
| { kind: "operator-restore" }
78
| { kind: "probe"; phase: string }
89

910
export function routeWorkerRequest(request: Request): WorkerRequestRoute {
@@ -13,5 +14,6 @@ export function routeWorkerRequest(request: Request): WorkerRequestRoute {
1314
if (phase === "r2-state") return { kind: "r2-state" }
1415
if (phase === "r2-mutate") return { kind: "r2-mutate" }
1516
if (phase === "operator-reset") return { kind: "operator-reset" }
17+
if (phase === "operator-restore") return { kind: "operator-restore" }
1618
return { kind: "probe", phase }
1719
}

packages/runtime-cloudflare/src/worker.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,7 @@ export default {
8686
const route = routeWorkerRequest(request)
8787
const coordinator = env.WORDPRESS_STATE.getByName("default")
8888
if (route.kind === "operator-reset") return resetCanonicalWordPress(request, env, coordinator)
89+
if (route.kind === "operator-restore") return restoreCanonicalWordPress(request, env, coordinator)
8990
if (route.kind === "probe") {
9091
return runBootProbe(route.phase, env.WORDPRESS_STATE_BUCKET)
9192
}
@@ -148,6 +149,46 @@ async function resetCanonicalWordPress(request: Request, env: Env, coordinator:
148149
return response
149150
}
150151

152+
async function restoreCanonicalWordPress(request: Request, env: Env, coordinator: DurableObjectStub): Promise<Response> {
153+
if (request.method !== "POST") return new Response("Canonical restore requires POST.", { status: 405 })
154+
const authorization = request.headers.get("authorization")
155+
if (!env.WORDPRESS_OPERATOR_TOKEN || !authorization || !await secretsMatch(authorization, `Bearer ${env.WORDPRESS_OPERATOR_TOKEN}`)) {
156+
return new Response("Canonical restore authorization failed.", { status: 401 })
157+
}
158+
let pointer: MarkdownPointer
159+
try {
160+
pointer = await request.json<MarkdownPointer>()
161+
} catch {
162+
return new Response("Canonical restore requires a JSON pointer.", { status: 400 })
163+
}
164+
if (!isCanonicalRestorePointer(pointer)) return new Response("Canonical restore pointer is invalid.", { status: 400 })
165+
const manifest = await readMarkdownManifest(env.WORDPRESS_STATE_BUCKET, pointer)
166+
if (!manifest || manifest.revision !== pointer.revision || manifest.manifestKey !== pointer.manifestKey || manifest.persistedAt !== pointer.persistedAt || !Array.isArray(manifest.files)) {
167+
return new Response("Canonical restore manifest is unavailable or inconsistent.", { status: 409 })
168+
}
169+
const lease = await acquireLease(coordinator, request.url)
170+
try {
171+
if (lease.pointer) {
172+
await abortLease(coordinator, request.url, lease)
173+
return new Response("Canonical restore requires an empty current pointer.", { status: 409 })
174+
}
175+
const restored = await commitLease(coordinator, request.url, lease, pointer)
176+
await discardCachedRuntime()
177+
return Response.json({ restored: true, ...restored })
178+
} catch (error) {
179+
await abortLease(coordinator, request.url, lease)
180+
throw error
181+
}
182+
}
183+
184+
function isCanonicalRestorePointer(pointer: unknown): pointer is MarkdownPointer {
185+
if (!pointer || typeof pointer !== "object") return false
186+
const candidate = pointer as Partial<MarkdownPointer>
187+
return typeof candidate.revision === "string" && /^[a-f0-9-]{36}$/.test(candidate.revision)
188+
&& candidate.manifestKey === `sites/default/markdown/revisions/${candidate.revision}.json`
189+
&& typeof candidate.persistedAt === "string" && Number.isFinite(Date.parse(candidate.persistedAt))
190+
}
191+
151192
async function secretsMatch(left: string, right: string): Promise<boolean> {
152193
const encoder = new TextEncoder()
153194
const [leftHash, rightHash] = await Promise.all([crypto.subtle.digest("SHA-256", encoder.encode(left)), crypto.subtle.digest("SHA-256", encoder.encode(right))])

tests/cloudflare-runtime.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ test("Cloudflare routing reserves phases while the phase-less route serves WordP
4242
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=r2-state")), { kind: "r2-state" })
4343
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=r2-mutate")), { kind: "r2-mutate" })
4444
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-reset")), { kind: "operator-reset" })
45+
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=operator-restore")), { kind: "operator-restore" })
4546
assert.deepEqual(routeWorkerRequest(new Request("https://worker.example/?phase=seeded-wordpress")), { kind: "probe", phase: "seeded-wordpress" })
4647
})
4748

@@ -235,6 +236,9 @@ test("Cloudflare keeps PHP-WASM in the entry Worker and uses the Durable Object
235236
assert.match(worker, /cookieStore: false/)
236237
assert.match(worker, /maxPhpInstances: 1/)
237238
assert.match(worker, /WORDPRESS_AUTH_SECRET/)
239+
assert.match(worker, /Canonical restore requires an empty current pointer/)
240+
assert.match(worker, /readMarkdownManifest\(env\.WORDPRESS_STATE_BUCKET, pointer\)/)
241+
assert.match(worker, /sites\/default\/markdown\/revisions\/\$\{candidate\.revision\}\.json/)
238242
assert.match(worker, /canonicalWordPressAuthConstants\(env\)/)
239243
assert.match(worker, /authConstants/)
240244
assert.match(worker, /const staticResponse = await serveWordPressStaticAsset\(request\)/)

0 commit comments

Comments
 (0)