Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CLOUDFLARE.md
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,21 @@ no unused generation timestamp. Pagefind still rebuilds as described above.

### Router deployment

Nested `/route/index.html` requests prefer the slashless HTML object and fall
back to the legacy nested key if canonical HTML is unavailable. The current
artifact still publishes both keys. Retiring duplicate objects is a two-stage
operation: first deploy and verify this compatible Worker, then publish an
artifact omitting the byte-identical nested copies. Record the authoritative
Worker version ID, source commit and successful deployment run in the retirement
receipt before the second stage; CI success alone does not establish that the
compatible reader is served. Verify canonical and explicit index URLs, including
GET/HEAD, locales, dotted paths, Markdown negotiation and asset responses.

Retirement uses a normal full manifest publication and its normal orphan cleanup;
a partial shell upload retains older keys. For rollback, restore the dual-key
artifact inventory before deploying a Worker that requires those legacy keys.
Keep the existing single-writer queue and stale-snapshot admission checks.

1. On a main push that changes `workers/**` or `wrangler.toml`, `r2-pages.yml` deploys the matching Worker after any required R2 upload, provided that snapshot passed admission before the build.
2. `pages.yml` pushes validate the Worker bundle with `wrangler deploy --dry-run`; they do not deploy it.
3. Manual `pages.yml` dispatch with `deploy_worker=true` deploys the router using the workflow's pinned Wrangler version.
Expand Down
71 changes: 70 additions & 1 deletion workers/docs-router-dotted.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ function harness(t, initial) {
if (!entry) return null;
return {
...(method === "GET" ? { body: entry.body } : {}),
size: Buffer.byteLength(entry.body), httpEtag: '"fixture"',
size: Buffer.byteLength(entry.body), httpEtag: entry.etag ?? '"fixture"',
httpMetadata: { contentType: entry.headers["Content-Type"] },
customMetadata: entry.customMetadata,
writeHttpMetadata(headers) {
Expand Down Expand Up @@ -72,6 +72,75 @@ function policy(response, browser, edge) {
assert.equal(response.headers.get("Cloudflare-CDN-Cache-Control"), edge);
}

for (const inventory of ["dual", "mixed", "canonical", "legacy-only"]) {
test(`nested index URLs preserve their response contract with ${inventory} objects`, async (t) => {
const keys = ["guide", "de/guide", "releases/2026.10.2", "docs/guide", "ümlaut/指南"];
const p = harness(t, {});
for (const key of keys) {
if (inventory !== "legacy-only") p.entries.set(key, {
...object("<html>Canonical</html>", htmlType, "/guide.md"), etag: '"canonical"',
});
if (inventory !== "canonical") p.entries.set(`${key}/index.html`, {
...object(inventory === "dual" ? "<html>Canonical</html>" : "<html>Legacy</html>", htmlType, "/guide.md"),
etag: inventory === "dual" ? '"canonical"' : '"legacy"',
});
const encoded = key.split("/").map(encodeURIComponent).join("/").replaceAll(".", "%2E");
for (const method of ["GET", "HEAD"]) for (const accept of ["text/html", ...markdownTypes]) {
p.reset();
const route = `/${encoded}/index.html?incoming=1`;
p.cache.set(`https://docs.openclaw.ai${route}`, new Response("Stale", { headers: { "Content-Type": htmlType } }));
const response = await p.request(route, method, accept);
assert.equal(response.status, 200);
assert.equal(response.headers.get("Content-Type"), htmlType);
assert.equal(response.headers.get("ETag"), inventory === "legacy-only" ? '"legacy"' : '"canonical"');
assert.equal(response.headers.get("Vary"), "Accept-Encoding", "explicit HTML does not negotiate Markdown");
assert.equal(response.headers.get("Link"), `</${encoded}.md>; rel="alternate"; type="text/markdown"`);
assert.equal(response.headers.get("x-amz-meta-openclaw-markdown-target"), "/guide.md");
assert.equal(response.headers.get("Location"), null);
assert.equal(response.headers.get("X-OpenClaw-Docs-Cache"), "MISS");
policy(response, htmlBrowser, htmlEdge);
assert.equal(await response.text(), method === "HEAD" ? "" : inventory === "legacy-only" ? "<html>Legacy</html>" : "<html>Canonical</html>");
assert.deepEqual(p.calls, inventory === "legacy-only" ? [`${method} ${key}`, `${method} ${key}/index.html`] : [`${method} ${key}`]);
assert.deepEqual(p.puts, []);
}
const redirect = await p.request(`/${encoded}/?incoming=1`);
assert.equal(redirect.status, 308);
assert.equal(redirect.headers.get("Location"), `https://docs.openclaw.ai/${encoded}?incoming=1`);
}
});
}

test("index compatibility does not expose non-HTML collisions or hide storage errors", async (t) => {
const p = harness(t, {
guide: object("Private asset bytes", "application/json"),
"guide/index.html": object("<html>Legacy</html>", htmlType),
absent: object("Non-HTML", "application/octet-stream"),
broken: new Error("synthetic R2 failure"),
"broken/index.html": object("<html>Stale</html>", htmlType),
"index.html": object("<html>Root</html>", htmlType),
});
for (const method of ["GET", "HEAD"]) {
p.reset();
const legacy = await p.request("/guide/index.html", method);
assert.equal(legacy.status, 200);
assert.equal(await legacy.text(), method === "HEAD" ? "" : "<html>Legacy</html>");
for (const route of ["/absent/index.html", "/missing/index.html"]) {
const missing = await p.request(route, method);
assert.equal(missing.status, 404);
assert.equal(missing.headers.get("Content-Type"), htmlType);
assert.doesNotMatch(await missing.text(), /Non-HTML/);
}
await assert.rejects(p.request("/broken/index.html", method), /synthetic R2 failure/);
for (const route of ["/", "/index.html"]) {
p.reset();
const root = await p.request(route, method);
assert.equal(root.status, 200);
assert.equal(await root.text(), method === "HEAD" ? "" : "<html>Root</html>");
assert.deepEqual(p.calls, [`${method} index.html`]);
}
}
});

async function representation(response, method, type, body, alternate = null) {
assert.equal(response.status, 200);
assert.equal(response.headers.get("Content-Type"), type);
Expand Down
16 changes: 15 additions & 1 deletion workers/docs-router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -507,7 +507,21 @@ async function r2Fetch(env: Env, method: string, key: string): Promise<Response>
headers: { "Content-Type": "text/plain; charset=utf-8" },
});
}
const object = method === "HEAD" ? await bucket.head(key) : await bucket.get(key);
const read = (objectKey: string) => method === "HEAD" ? bucket.head(objectKey) : bucket.get(objectKey);
let object;
if (key.endsWith("/index.html") && key.length > "/index.html".length) {
// Nested HTML copies can retire after this reader is deployed. Prefer the
// canonical slashless object, but keep serving older/partial inventories.
// Resolve only the storage key: URL, negotiation and cache policy stay put.
const canonical = await read(key.slice(0, -"/index.html".length));
if (canonical) {
const metadata = new Headers();
canonical.writeHttpMetadata(metadata);
if (/^text\/html(?:;|$)/i.test(metadata.get("Content-Type") ?? "")) object = canonical;
else if ("body" in canonical) await canonical.body.cancel?.();
}
}
object ??= await read(key);
if (!object) {
return new Response(method === "HEAD" ? null : "Not found\n", {
status: 404,
Expand Down
Loading