summaryrefslogtreecommitdiff
path: root/workers/branch/src/index.ts
blob: 47ef3ad651aaa46bb2e3c1d2313c1a12a0547ad9 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
export interface Env {
  ASSETS: Fetcher;                 // static assets binding
  DOCS_BUILD_SHA: string;          // injected at deploy
  DOCS_ENV: "production" | "canary";
}

export type CacheClass = "page" | "asset";

// Binary/media asset extensions (case-insensitive, so ".PDF" also matches) get the longer
// asset cache class, alongside the Sphinx /_static/ (theme) + /_images/ (figure) trees.
const ASSET_EXT_RE = /\.(pdf|png|jpe?g|webp|svg|gif|ico|woff2?|ttf|otf|eot)$/i;

export function classifyPath(path: string): CacheClass {
  if (
    path.includes("/_static/") ||
    path.includes("/_images/") ||
    ASSET_EXT_RE.test(path)
  )
    return "asset";
  return "page"; // HTML, versions.json, sitemaps, robots/llms, pagefind index
}

export function cacheHeaderFor(cls: CacheClass): string {
  return cls === "asset"
    ? "public, max-age=300, s-maxage=600, must-revalidate"
    : "public, max-age=0, s-maxage=300, must-revalidate";
}

export function withDocsHeaders(
  resp: Response,
  path: string,
  env: Pick<Env, "DOCS_BUILD_SHA" | "DOCS_ENV">,
): Response {
  const out = new Response(resp.body, resp);
  out.headers.set("X-Docs-Build", env.DOCS_BUILD_SHA);
  out.headers.set(
    "Cache-Control",
    // Error responses (4xx/5xx) must never carry the page/asset cache class — a
    // cached 404 would poison the edge for the full s-maxage window.
    env.DOCS_ENV === "canary" || out.status >= 400
      ? "no-store"
      : cacheHeaderFor(classifyPath(path)),
  );
  return out;
}

export default {
  async fetch(request: Request, env: Env): Promise<Response> {
    const url = new URL(request.url);
    // With assets html_handling "none", the runtime serves explicit .html URLs directly
    // but does NOT map a directory URL ("/foo/") to its index.html — the worker must map
    // trailing-slash URLs to index.html itself to preserve ReadTheDocs URL parity.
    if (url.pathname.endsWith("/")) {
      const mapped = new URL(url);
      mapped.pathname = url.pathname + "index.html";
      const resp = await env.ASSETS.fetch(new Request(mapped, request));
      return withDocsHeaders(resp, url.pathname, env);
    }
    // Bare extensionless path (no trailing slash, no "." in the last segment): it may be a
    // real directory whose slashed form RTD 301-redirects to ("/foo" → "/foo/"), or a
    // file-like path with no matching asset (e.g. "/cli", whose real asset is "cli.html")
    // that RTD 404s. A dot heuristic can't separate them, so probe the assets binding for
    // "<path>/index.html": 200 → 301 to the slashed form; anything else → fall through to
    // the exact-path fetch (404 for "/cli", matching live RTD).
    const lastSegment = url.pathname.slice(url.pathname.lastIndexOf("/") + 1);
    if (lastSegment !== "" && !lastSegment.includes(".")) {
      const probe = new URL(url);
      probe.pathname = url.pathname + "/index.html";
      const probeResp = await env.ASSETS.fetch(new Request(probe, { method: "GET" }));
      probeResp.body?.cancel(); // existence check only — release the probe body stream
      if (probeResp.status === 200) {
        const location = url.pathname + "/" + url.search; // preserve query; no fragment
        return withDocsHeaders(
          new Response(null, { status: 301, headers: { Location: location } }),
          url.pathname,
          env,
        );
      }
    }
    const resp = await env.ASSETS.fetch(request);
    return withDocsHeaders(resp, url.pathname, env);
  },
} satisfies ExportedHandler<Env>;