Skip to content

Commit 0a4ffd6

Browse files
authored
Merge pull request #1980 from Automattic/feat/1975-cloudflare-static-artifact-import
Import static artifacts into Cloudflare WordPress
2 parents 8d01722 + 7b3781b commit 0a4ffd6

11 files changed

Lines changed: 565 additions & 26 deletions

packages/runtime-cloudflare/README.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,15 @@ The bundled MDI source is pinned to immutable commit `bf6d434d1673fdd86d777501f7
2525
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.
2626

2727
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 outside the explicit scheduled-Worker transaction.
28+
29+
## Static Artifact Import
30+
31+
An authenticated `POST ?phase=operator-static-artifact-import` materializes a verified website artifact into the existing canonical site. The request is limited to 16 KiB and references an immutable JSON object at `sites/default/import-artifacts/<sha256>.json`; the Worker requires the declared R2 key, size, and SHA-256 to agree before acquiring a coordinator lease. The canonical `blocks-engine/php-transformer/site-artifact/v1` payload is limited to 4 MiB serialized, 500 safe unique files, 8 MiB per decoded file, and 32 MiB decoded in aggregate.
32+
33+
The import transaction boots a dedicated runtime with the pinned Static Site Importer v1.3.4 archive (`8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29`) from R2, invokes its public `static-site-importer/import-website-artifact` ability as the operator-authorized administrator, and requires the canonical quality gate plus zero fallback, core HTML, freeform, and invalid blocks. SSI and its MU loader are runtime-owned and excluded from canonical mutable `wp-content`; generated themes, pages, options, and assets persist normally through MDI/R2. Failed or partial imports discard the PHP runtime without committing. Successful imports return compact provenance and canonical revision/version headers, enqueue affected publication routes, and store at most 20 idempotency receipts in canonical options. Exact replay returns the existing receipt without a new revision; reuse with different input returns a conflict.
34+
35+
SSI is extracted only for import requests, so normal browser, mutation, publication, and cron boots retain their existing memory and latency profile. The pinned normal plugin archive bundles Blocks Engine and supports website artifacts without the optional Figma zstd extension; compressed `.fig` import is outside this runtime contract.
36+
2837
## Verification
2938

3039
1. Run `npm run generate:cloudflare-canonical-mdi-seed` and `npm run generate:cloudflare-wordpress-runtime-corpus` to regenerate deterministic runtime artifacts and manifests.
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": "static-site-importer",
4+
"key": "runtime/archives/static-site-importer/8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29.zip",
5+
"archive": {
6+
"sha256": "8d27286021d7c6141609def40a97591322a14340b23a17d9405f7919ea145a29",
7+
"size": 13056371
8+
},
9+
"source": {
10+
"url": "https://github.com/Automattic/static-site-importer/releases/download/v1.3.4/static-site-importer.zip",
11+
"version": "1.3.4",
12+
"identity": "08b9dd650f3c3161c5b350796a5db6ef083516ae"
13+
}
14+
}

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ export type WorkerRequestRoute =
66
| { kind: "operator-reset" }
77
| { kind: "operator-restore" }
88
| { kind: "operator-adopt" }
9+
| { kind: "operator-static-artifact-import" }
910
| { kind: "operator-publish" }
1011
| { kind: "probe"; phase: string }
1112

@@ -18,6 +19,7 @@ export function routeWorkerRequest(request: Request): WorkerRequestRoute {
1819
if (phase === "operator-reset") return { kind: "operator-reset" }
1920
if (phase === "operator-restore") return { kind: "operator-restore" }
2021
if (phase === "operator-adopt") return { kind: "operator-adopt" }
22+
if (phase === "operator-static-artifact-import") return { kind: "operator-static-artifact-import" }
2123
if (phase === "operator-publish") return { kind: "operator-publish" }
2224
return { kind: "probe", phase }
2325
}

packages/runtime-cloudflare/src/runtime-archive-artifact.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
export const RUNTIME_ARCHIVE_ARTIFACT_SCHEMA = "wp-codebox/runtime-archive-artifact/v1"
2-
export const RUNTIME_ARCHIVE_MAX_BYTES = 8 * 1024 * 1024
2+
export const RUNTIME_ARCHIVE_MAX_BYTES = 16 * 1024 * 1024
33

44
export interface RuntimeArchiveArtifactManifest {
55
schema: typeof RUNTIME_ARCHIVE_ARTIFACT_SCHEMA
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
export const STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA = "wp-codebox/cloudflare-static-artifact-import-request/v1"
2+
export const STATIC_ARTIFACT_IMPORT_RESULT_SCHEMA = "wp-codebox/cloudflare-static-artifact-import-result/v1"
3+
export const STATIC_ARTIFACT_SCHEMA = "blocks-engine/php-transformer/site-artifact/v1"
4+
export const R2_STATIC_ARTIFACT_PREFIX = "sites/default/import-artifacts"
5+
export const MAX_STATIC_ARTIFACT_REQUEST_BYTES = 16 * 1024
6+
export const MAX_STATIC_ARTIFACT_BYTES = 4 * 1024 * 1024
7+
export const MAX_STATIC_ARTIFACT_FILES = 500
8+
export const MAX_STATIC_ARTIFACT_FILE_BYTES = 8 * 1024 * 1024
9+
export const MAX_STATIC_ARTIFACT_DECODED_BYTES = 32 * 1024 * 1024
10+
11+
export interface StaticArtifactImport {
12+
idempotencyKey: string
13+
fingerprint: string
14+
artifactReference: { r2Key: string; sha256: string; size: number }
15+
artifact: Record<string, unknown>
16+
options: { slug: string; name: string; siteTitle: string }
17+
}
18+
19+
export async function readStaticArtifactImport(request: Request, bucket: R2Bucket): Promise<StaticArtifactImport> {
20+
const declaredLength = request.headers.get("content-length")
21+
if (declaredLength && (!/^\d+$/.test(declaredLength) || Number(declaredLength) > MAX_STATIC_ARTIFACT_REQUEST_BYTES)) throw new StaticArtifactImportError("Static artifact import request exceeds its byte budget.", 413)
22+
const requestBytes = await readBoundedRequestBytes(request)
23+
let body: unknown
24+
try {
25+
body = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(requestBytes))
26+
} catch {
27+
throw new StaticArtifactImportError("Static artifact import request must be valid UTF-8 JSON.", 400)
28+
}
29+
if (!body || typeof body !== "object" || Array.isArray(body)) throw new StaticArtifactImportError("Static artifact import request must be an object.", 400)
30+
const candidate = body as Record<string, unknown>
31+
if (candidate.schema !== STATIC_ARTIFACT_IMPORT_REQUEST_SCHEMA || typeof candidate.idempotencyKey !== "string" || !/^[A-Za-z0-9._:-]{1,128}$/.test(candidate.idempotencyKey)) {
32+
throw new StaticArtifactImportError("Static artifact import request identity is invalid.", 400)
33+
}
34+
const reference = candidate.artifact
35+
if (!reference || typeof reference !== "object" || Array.isArray(reference)) throw new StaticArtifactImportError("Static artifact reference is required.", 400)
36+
const artifactReference = reference as Record<string, unknown>
37+
if (typeof artifactReference.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(artifactReference.sha256)
38+
|| artifactReference.r2Key !== `${R2_STATIC_ARTIFACT_PREFIX}/${artifactReference.sha256}.json`
39+
|| !Number.isSafeInteger(artifactReference.size) || (artifactReference.size as number) < 1 || (artifactReference.size as number) > MAX_STATIC_ARTIFACT_BYTES) {
40+
throw new StaticArtifactImportError("Static artifact reference is invalid or outside its byte budget.", 400)
41+
}
42+
const options = candidate.import
43+
if (!options || typeof options !== "object" || Array.isArray(options)) throw new StaticArtifactImportError("Static artifact import options are required.", 400)
44+
const importOptions = options as Record<string, unknown>
45+
const slug = typeof importOptions.slug === "string" ? importOptions.slug : ""
46+
const name = typeof importOptions.name === "string" ? importOptions.name.trim() : ""
47+
const siteTitle = typeof importOptions.siteTitle === "string" ? importOptions.siteTitle.trim() : ""
48+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug.length > 64 || !name || name.length > 120 || siteTitle.length > 120) throw new StaticArtifactImportError("Static artifact import options are invalid.", 400)
49+
50+
const object = await bucket.get(artifactReference.r2Key as string)
51+
if (!object) throw new StaticArtifactImportError("Static artifact is unavailable.", 404)
52+
if (object.size !== artifactReference.size) throw new StaticArtifactImportError("Static artifact size does not match its reference.", 409)
53+
const bytes = new Uint8Array(await object.arrayBuffer())
54+
if (await sha256Hex(bytes) !== artifactReference.sha256) throw new StaticArtifactImportError("Static artifact digest does not match its reference.", 409)
55+
let artifact: unknown
56+
try {
57+
artifact = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes))
58+
} catch {
59+
throw new StaticArtifactImportError("Static artifact must be valid UTF-8 JSON.", 422)
60+
}
61+
await validateStaticArtifact(artifact)
62+
const fingerprint = await sha256Hex(new TextEncoder().encode(JSON.stringify({ sha256: artifactReference.sha256, slug, name, siteTitle })))
63+
return {
64+
idempotencyKey: candidate.idempotencyKey,
65+
fingerprint,
66+
artifactReference: { r2Key: artifactReference.r2Key as string, sha256: artifactReference.sha256, size: artifactReference.size as number },
67+
artifact: artifact as Record<string, unknown>,
68+
options: { slug, name, siteTitle },
69+
}
70+
}
71+
72+
export class StaticArtifactImportError extends Error {
73+
constructor(message: string, readonly status: number) {
74+
super(message)
75+
}
76+
}
77+
78+
async function readBoundedRequestBytes(request: Request): Promise<Uint8Array> {
79+
if (!request.body) return new Uint8Array()
80+
const reader = request.body.getReader()
81+
const chunks: Uint8Array[] = []
82+
let total = 0
83+
while (true) {
84+
const { done, value } = await reader.read()
85+
if (done) break
86+
total += value.byteLength
87+
if (total > MAX_STATIC_ARTIFACT_REQUEST_BYTES) {
88+
await reader.cancel()
89+
throw new StaticArtifactImportError("Static artifact import request exceeds its byte budget.", 413)
90+
}
91+
chunks.push(value)
92+
}
93+
const bytes = new Uint8Array(total)
94+
let offset = 0
95+
for (const chunk of chunks) {
96+
bytes.set(chunk, offset)
97+
offset += chunk.byteLength
98+
}
99+
return bytes
100+
}
101+
102+
async function validateStaticArtifact(value: unknown): Promise<void> {
103+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new StaticArtifactImportError("Static artifact must be an object.", 422)
104+
const artifact = value as Record<string, unknown>
105+
if (artifact.schema !== STATIC_ARTIFACT_SCHEMA || typeof artifact.root !== "string" || !isSafePath(artifact.root)
106+
|| typeof artifact.entrypoint !== "string" || !isSafePath(artifact.entrypoint) || !Array.isArray(artifact.files)
107+
|| artifact.files.length < 1 || artifact.files.length > MAX_STATIC_ARTIFACT_FILES) {
108+
throw new StaticArtifactImportError("Static artifact contract is invalid.", 422)
109+
}
110+
const paths = new Set<string>()
111+
let decodedBytes = 0
112+
for (const file of artifact.files) {
113+
if (!file || typeof file !== "object" || Array.isArray(file)) throw new StaticArtifactImportError("Static artifact contains an invalid file.", 422)
114+
const candidate = file as Record<string, unknown>
115+
if (typeof candidate.path !== "string" || !isSafePath(candidate.path) || paths.has(candidate.path) || !(candidate.path === artifact.root || candidate.path.startsWith(`${artifact.root}/`))) {
116+
throw new StaticArtifactImportError("Static artifact contains an invalid file path.", 422)
117+
}
118+
paths.add(candidate.path)
119+
let bytes: Uint8Array
120+
if (typeof candidate.content === "string" && candidate.content_base64 === undefined) bytes = new TextEncoder().encode(candidate.content)
121+
else if (typeof candidate.content_base64 === "string" && candidate.content === undefined) bytes = decodeBase64(candidate.content_base64)
122+
else throw new StaticArtifactImportError("Static artifact file content is invalid.", 422)
123+
if (bytes.byteLength > MAX_STATIC_ARTIFACT_FILE_BYTES) throw new StaticArtifactImportError("Static artifact file exceeds its byte budget.", 413)
124+
decodedBytes += bytes.byteLength
125+
if (decodedBytes > MAX_STATIC_ARTIFACT_DECODED_BYTES) throw new StaticArtifactImportError("Static artifact exceeds its decoded byte budget.", 413)
126+
if (candidate.bytes !== undefined && candidate.bytes !== bytes.byteLength) throw new StaticArtifactImportError("Static artifact file size does not match its metadata.", 422)
127+
if (candidate.sha256 !== undefined && (typeof candidate.sha256 !== "string" || !/^[a-f0-9]{64}$/.test(candidate.sha256) || await sha256Hex(bytes) !== candidate.sha256)) throw new StaticArtifactImportError("Static artifact file digest does not match its content.", 422)
128+
}
129+
if (!paths.has(artifact.entrypoint)) throw new StaticArtifactImportError("Static artifact entrypoint is unavailable.", 422)
130+
}
131+
132+
function isSafePath(path: string): boolean {
133+
return path.length > 0 && !path.startsWith("/") && !path.includes("\\") && path.split("/").every((segment) => !!segment && segment !== "." && segment !== "..")
134+
}
135+
136+
function decodeBase64(value: string): Uint8Array {
137+
if (!/^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/.test(value)) throw new StaticArtifactImportError("Static artifact contains invalid base64 content.", 422)
138+
const decoded = atob(value)
139+
return Uint8Array.from(decoded, (character) => character.charCodeAt(0))
140+
}
141+
142+
async function sha256Hex(bytes: Uint8Array): Promise<string> {
143+
const digest = await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>)
144+
return Array.from(new Uint8Array(digest), (byte) => byte.toString(16).padStart(2, "0")).join("")
145+
}

0 commit comments

Comments
 (0)