Skip to content

Commit 8af4932

Browse files
committed
Handle documentation entry queries across the classic and Astro cutover
1 parent d5ddce1 commit 8af4932

7 files changed

Lines changed: 97 additions & 23 deletions

File tree

docs/deployment-runbook.md

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -243,8 +243,15 @@ Worker route remains sufficient rollback.
243243
The narrow canary route is more specific than `/doc/*` and would otherwise
244244
keep intercepting that version.
245245

246-
The production documentation routes are `/doc`, `/doc/*`, `/doc-latest`,
247-
`/doc-latest/*` and `/robots.txt`. The Worker serves the same checked-in robots
246+
The production route patterns are `www.gecode.dev/doc*` and
247+
`www.gecode.dev/robots.txt*`. Cloudflare matches query strings too, so the
248+
trailing wildcards cover bare entry points with queries. The Worker handles
249+
only `/doc`, `/doc/...`, `/doc-latest`, `/doc-latest/...` and `/robots.txt`;
250+
it passes other matching website paths through to the origin unchanged.
251+
`/doc` redirects to `/documentation.html`, which works during the classic-site
252+
soak and through the Astro fallback afterward.
253+
254+
The Worker serves the same checked-in robots
248255
file as Astro so the indexing policy takes effect before the website cutover.
249256
It selects aliases through `LATEST_DOC_VERSION`; it does not copy alias objects.
250257
Only `https://www.gecode.dev/doc/latest/...` is indexable. Versioned URLs remain

docs/static-documentation-hosting.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ The active Astro site remains free on GitHub Pages.
2929

3030
```text
3131
www.gecode.dev/* GitHub Pages (Astro)
32-
www.gecode.dev/doc/* Cloudflare Worker route
33-
www.gecode.dev/doc-latest/* Cloudflare Worker route
32+
www.gecode.dev/doc* Cloudflare Worker route
33+
www.gecode.dev/robots.txt* Cloudflare Worker route
3434
|
3535
v
3636
private R2 bucket
@@ -221,7 +221,9 @@ versioned key in R2.
221221
- Proxy the `www` record while retaining its GitHub Pages CNAME origin.
222222
- Test the staging custom domain, then deploy the checked-in canary environment.
223223
- After the canary passes, install the production routes for
224-
`www.gecode.dev/doc/*` and `www.gecode.dev/doc-latest/*`.
224+
`www.gecode.dev/doc*` and `www.gecode.dev/robots.txt*`. The trailing wildcard
225+
includes query strings; the Worker passes similarly prefixed website paths
226+
outside its documentation namespace to the origin unchanged.
225227
- Remove the canary after the production smoke test so its more-specific route
226228
no longer intercepts the selected version.
227229
- Compare status, body digest, MIME type, cache headers, and range behavior

scripts/docs/smoke-worker.mjs

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,20 @@ for (const prefix of immutableOnly ? [`/doc/${version}`] : [`/doc/${version}`, "
4646

4747
if (!immutableOnly) {
4848
await check("/doc?smoke=1", 308, {}, (response) => {
49-
assert.equal(response.headers.get("location"), `${origin}/documentation/?smoke=1`);
49+
assert.equal(response.headers.get("location"), `${origin}/documentation.html?smoke=1`);
5050
});
51+
await check("/robots.txt?smoke=1", 200, {}, async (response) => {
52+
assert.match(response.headers.get("content-type"), /text\/plain/);
53+
const body = await response.text();
54+
assert.doesNotMatch(body, /^Disallow:\s*\/doc(?:\/latest|-latest)/m);
55+
assert.match(body, /^Sitemap: https:\/\/www\.gecode\.dev\/doc\/sitemap\.xml$/m);
56+
});
57+
if (production) {
58+
await check("/documentation.html?smoke=1", 200, { redirect: "follow" }, (response) => {
59+
assertNoindex(response, false);
60+
assert.equal(response.headers.get("x-gecode-documentation-version"), null);
61+
});
62+
}
5163
}
5264
await check(`/doc/${version}/reference/doxygen.css`, 200, {}, (response) => {
5365
assert.match(response.headers.get("content-type"), /text\/css/);

workers/docs/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,13 @@ version URLs, including PDFs, carry `X-Robots-Tag: noindex`. The
2121
`noindex`; staging documentation is also `noindex`. These paths remain
2222
crawlable so search engines can read the indexing headers.
2323

24+
Production routes use `doc*` and `robots.txt*` because Cloudflare matches query
25+
strings against route patterns. Requests outside the exact documentation
26+
namespaces, such as `/documentation.html`, pass through to the production
27+
origin without documentation indexing headers. Unknown staging paths return
28+
404, avoiding a fetch back into the custom-domain Worker. The `/doc` landing
29+
redirect uses `/documentation.html`, which exists before and after Astro.
30+
2431
## Local validation
2532

2633
Run all Worker integration tests and compile the production configuration:

workers/docs/src/index.test.ts

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -317,15 +317,61 @@ describe("documentation worker", () => {
317317
expect(response.headers.get("location")).toBe(`${base}${path}/?view=1`);
318318
}
319319
expect((await request("/doc/6.4.0/missing-directory")).status).toBe(404);
320-
expect((await request("/doc")).headers.get("location")).toBe(`${base}/documentation/`);
320+
for (const path of ["/doc", "/doc/", "/doc?smoke=1", "/doc/?smoke=1"]) {
321+
const response = await request(path);
322+
expect(response.status).toBe(308);
323+
expect(response.headers.get("location")).toBe(`${base}/documentation.html${path.includes("?") ? "?smoke=1" : ""}`);
324+
}
325+
});
326+
327+
it("passes neighboring website paths through without changing requests or responses", async () => {
328+
const originFetch = vi.spyOn(globalThis, "fetch");
329+
try {
330+
for (const [path, method, status] of [
331+
["/documentation.html?smoke=1", "GET", 200],
332+
["/documentation/", "HEAD", 404],
333+
["/documents/submit?draft=1", "POST", 201],
334+
["/doc-latest-news.html", "GET", 200],
335+
["/robots.txt.bak?download=1", "GET", 404],
336+
] as const) {
337+
const incoming = new Request(`${base}${path}`, {
338+
method, headers: { "X-Request-Test": "preserved" },
339+
body: method === "POST" ? "submission bytes" : undefined,
340+
});
341+
const upstream = new Response(method === "HEAD" ? null : "origin content", {
342+
status,
343+
headers: {
344+
"Content-Type": "text/html",
345+
"X-Robots-Tag": "index, follow",
346+
Link: '<https://www.gecode.dev/documentation/>; rel="canonical"',
347+
},
348+
});
349+
originFetch.mockResolvedValueOnce(upstream);
350+
const context = createExecutionContext();
351+
const response = await worker.fetch(incoming, env, context);
352+
await waitOnExecutionContext(context);
353+
expect(originFetch).toHaveBeenLastCalledWith(incoming);
354+
expect(response).toBe(upstream);
355+
expect(response.status).toBe(status);
356+
expect(response.headers.get("x-robots-tag")).toBe("index, follow");
357+
expect(response.headers.get("link")).toBe('<https://www.gecode.dev/documentation/>; rel="canonical"');
358+
if (method === "POST") expect(await incoming.text()).toBe("submission bytes");
359+
}
360+
const staging = await request("https://docs-staging.gecode.dev/documentation.html");
361+
expect(staging.status).toBe(404);
362+
expect(staging.headers.get("x-robots-tag")).toBe("noindex");
363+
expect(originFetch).toHaveBeenCalledTimes(5);
364+
} finally {
365+
originFetch.mockRestore();
366+
}
321367
});
322368

323369
it("returns explicit errors", async () => {
324370
expect((await request("/doc/6.4.0/missing.html")).status).toBe(404);
325371
const method = await request("/doc/6.4.0/index.html", { method: "POST" });
326372
expect(method.status).toBe(405);
327373
expect(method.headers.get("allow")).toBe("GET, HEAD");
328-
expect((await request("/doc/%2e%2e/secret")).status).toBe(400);
374+
expect((await request("/doc/6.4.0/%")).status).toBe(400);
329375
expect((await request("/doc/6.4.0/%252e%252e/secret")).status).toBe(400);
330376
expect((await request("/doc/6.4.0/reference%2fPageChange.html")).status).toBe(400);
331377
});

workers/docs/src/index.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro
185185
destination.pathname = pathname;
186186
return Response.redirect(destination.href, 308);
187187
};
188-
if (url.pathname === "/doc" || url.pathname === "/doc/") return redirect("/documentation/");
188+
if (url.pathname === "/doc" || url.pathname === "/doc/") return redirect("/documentation.html");
189189
const resolved = resolvePath(url.pathname, env.LATEST_DOC_VERSION);
190190
if (!resolved) return errorResponse(400, "Invalid documentation path");
191191

@@ -285,6 +285,18 @@ async function serve(request: Request, env: Env, context: ExecutionContext): Pro
285285

286286
export default {
287287
async fetch(request: Request, env: Env, context: ExecutionContext): Promise<Response> {
288+
const url = new URL(request.url);
289+
const pathname = url.pathname;
290+
const ownsPath = pathname === "/robots.txt"
291+
|| pathname === "/doc" || pathname.startsWith("/doc/")
292+
|| pathname === "/doc-latest" || pathname.startsWith("/doc-latest/");
293+
// Wildcard routes also receive /documentation.html and similarly named
294+
// website paths. Leave their origin response and indexing headers intact.
295+
if (!ownsPath) {
296+
if (url.hostname === "www.gecode.dev") return fetch(request);
297+
return applyIndexingPolicy(request, errorResponse(404, "Page not found"), env);
298+
}
299+
288300
let response: Response;
289301
try {
290302
response = await serve(request, env, context);

workers/docs/wrangler.jsonc

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,23 +51,11 @@
5151
"observability": { "enabled": true },
5252
"routes": [
5353
{
54-
"pattern": "www.gecode.dev/robots.txt",
54+
"pattern": "www.gecode.dev/robots.txt*",
5555
"zone_name": "gecode.dev"
5656
},
5757
{
58-
"pattern": "www.gecode.dev/doc",
59-
"zone_name": "gecode.dev"
60-
},
61-
{
62-
"pattern": "www.gecode.dev/doc/*",
63-
"zone_name": "gecode.dev"
64-
},
65-
{
66-
"pattern": "www.gecode.dev/doc-latest",
67-
"zone_name": "gecode.dev"
68-
},
69-
{
70-
"pattern": "www.gecode.dev/doc-latest/*",
58+
"pattern": "www.gecode.dev/doc*",
7159
"zone_name": "gecode.dev"
7260
}
7361
],

0 commit comments

Comments
 (0)