diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ae77ed1..5c814dc 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -83,6 +83,10 @@ importers:
version: 4.97.0
router:
+ dependencies:
+ hono:
+ specifier: ^4.13.2
+ version: 4.13.2
devDependencies:
'@cloudflare/workers-types':
specifier: ^4.20250327.0
@@ -1961,6 +1965,10 @@ packages:
hastscript@9.0.1:
resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==}
+ hono@4.13.2:
+ resolution: {integrity: sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==}
+ engines: {node: '>=16.9.0'}
+
html-escaper@3.0.3:
resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==}
@@ -4875,6 +4883,8 @@ snapshots:
property-information: 7.1.0
space-separated-tokens: 2.0.2
+ hono@4.13.2: {}
+
html-escaper@3.0.3: {}
html-void-elements@3.0.0: {}
diff --git a/router/package.json b/router/package.json
index a8e3b6d..c198360 100644
--- a/router/package.json
+++ b/router/package.json
@@ -11,5 +11,8 @@
"devDependencies": {
"@cloudflare/workers-types": "^4.20250327.0",
"wrangler": "^4.14.4"
+ },
+ "dependencies": {
+ "hono": "^4.13.2"
}
}
diff --git a/router/src/app.test.ts b/router/src/app.test.ts
new file mode 100644
index 0000000..ecfe05f
--- /dev/null
+++ b/router/src/app.test.ts
@@ -0,0 +1,135 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { createApp } from "./app.ts";
+
+/** App with a scripted upstream; unmatched paths 404 like the assets Worker. */
+function appWith(handler: (url: URL) => Response | undefined) {
+ const calls: URL[] = [];
+ const app = createApp({
+ upstream: async (url) => {
+ calls.push(url);
+ return handler(url) ?? new Response("not found", { status: 404 });
+ },
+ passthrough: async (request) =>
+ new Response(`passthrough:${request.url}`),
+ });
+ return { app, calls };
+}
+
+const html = () =>
+ new Response("page", {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ });
+
+const markdown = () =>
+ new Response("# Page\n", {
+ status: 200,
+ headers: { "Content-Type": "text/markdown; charset=utf-8" },
+ });
+
+test("redirects bare project roots to their landing page", async () => {
+ const { app } = appWith(() => undefined);
+ const res = await app.request("https://bomb.sh/docs/clack", {
+ headers: { Accept: "text/markdown" },
+ });
+ assert.equal(res.status, 308);
+ assert.equal(
+ res.headers.get("Location"),
+ "https://bomb.sh/docs/clack/basics/getting-started/",
+ );
+});
+
+test("serves the markdown twin on Accept: text/markdown", async () => {
+ const { app, calls } = appWith((url) =>
+ url.pathname === "/docs/args/api/index.md" ? markdown() : undefined,
+ );
+ const res = await app.request("https://bomb.sh/docs/args/api/", {
+ headers: { Accept: "text/markdown" },
+ });
+ assert.equal(res.status, 200);
+ assert.equal(calls[0].href, "https://docs.bomb.sh/docs/args/api/index.md");
+ assert.match(res.headers.get("Content-Type") ?? "", /text\/markdown/);
+ assert.equal(res.headers.get("Vary"), "Accept");
+ assert.equal(
+ res.headers.get("Link"),
+ '; rel="canonical"',
+ );
+ assert.equal(await res.text(), "# Page\n");
+});
+
+test("serves markdown for explicit .md paths without an Accept header", async () => {
+ const { app, calls } = appWith((url) =>
+ url.pathname === "/docs/args/api/index.md" ? markdown() : undefined,
+ );
+ const res = await app.request("https://bomb.sh/docs/args/api.md");
+ assert.equal(res.status, 200);
+ assert.equal(calls[0].href, "https://docs.bomb.sh/docs/args/api/index.md");
+});
+
+test("negotiates the docs root to the markdown index", async () => {
+ const { app, calls } = appWith((url) =>
+ url.pathname === "/docs/index.md" ? markdown() : undefined,
+ );
+ const res = await app.request("https://bomb.sh/docs", {
+ headers: { Accept: "text/markdown" },
+ });
+ assert.equal(res.status, 200);
+ assert.equal(calls[0].href, "https://docs.bomb.sh/docs/index.md");
+});
+
+test("returns markdown 404 guidance when the twin is missing", async () => {
+ const { app } = appWith(() => undefined);
+ const res = await app.request("https://bomb.sh/docs/nope/", {
+ headers: { Accept: "text/markdown" },
+ });
+ assert.equal(res.status, 404);
+ assert.match(res.headers.get("Content-Type") ?? "", /text\/markdown/);
+ assert.match(await res.text(), /https:\/\/bomb\.sh\/docs\/index\.md/);
+});
+
+test("proxies HTML with security headers and a markdown alternate link", async () => {
+ const { app } = appWith((url) =>
+ url.pathname === "/docs/args/api/" ? html() : undefined,
+ );
+ const res = await app.request("https://bomb.sh/docs/args/api/");
+ assert.equal(res.status, 200);
+ assert.equal(
+ res.headers.get("Cross-Origin-Embedder-Policy"),
+ "require-corp",
+ );
+ assert.equal(
+ res.headers.get("Link"),
+ '; rel="alternate"; type="text/markdown"',
+ );
+ assert.equal(await res.text(), "page");
+});
+
+test("maps missing pages to the Starlight 404 page with status 404", async () => {
+ const { app } = appWith((url) =>
+ url.pathname === "/docs/404.html"
+ ? new Response("404", {
+ status: 200,
+ headers: { "Content-Type": "text/html" },
+ })
+ : undefined,
+ );
+ const res = await app.request("https://bomb.sh/docs/missing/");
+ assert.equal(res.status, 404);
+ assert.equal(await res.text(), "404");
+});
+
+test("does not negotiate asset paths", async () => {
+ const { app, calls } = appWith(() => html());
+ await app.request("https://bomb.sh/docs/og-docs.png", {
+ headers: { Accept: "text/markdown" },
+ });
+ assert.equal(calls[0].href, "https://docs.bomb.sh/docs/og-docs.png");
+});
+
+test("passes non-docs requests through untouched", async () => {
+ const { app, calls } = appWith(() => undefined);
+ const res = await app.request("https://bomb.sh/other");
+ assert.equal(await res.text(), "passthrough:https://bomb.sh/other");
+ assert.equal(calls.length, 0);
+});
diff --git a/router/src/app.ts b/router/src/app.ts
new file mode 100644
index 0000000..ea7d879
--- /dev/null
+++ b/router/src/app.ts
@@ -0,0 +1,27 @@
+import { Hono } from "hono/tiny";
+import { projectRedirects } from "./redirects.ts";
+import { markdownNegotiation } from "./markdown.ts";
+import { docsProxy } from "./proxy.ts";
+import { defaultUpstream, type Upstream } from "./upstream.ts";
+
+export interface AppOptions {
+ /** Fetches from the docs assets Worker. */
+ upstream?: Upstream;
+ /** Handles non-docs requests. */
+ passthrough?: (request: Request) => Promise;
+}
+
+export function createApp(options: AppOptions = {}) {
+ const upstream = options.upstream ?? defaultUpstream;
+ const passthrough =
+ options.passthrough ?? ((request: Request) => fetch(request));
+
+ const app = new Hono();
+ for (const path of ["/docs", "/docs/*"]) {
+ app.use(path, projectRedirects());
+ app.use(path, markdownNegotiation(upstream));
+ app.all(path, docsProxy(upstream));
+ }
+ app.all("*", (c) => passthrough(c.req.raw));
+ return app;
+}
diff --git a/router/src/config.ts b/router/src/config.ts
new file mode 100644
index 0000000..01aa10c
--- /dev/null
+++ b/router/src/config.ts
@@ -0,0 +1,12 @@
+// Public origin used in Link headers (canonical/alternate) so agents always
+// discover the proxied bomb.sh URLs, never the internal workers.dev ones.
+export const SITE = "https://bomb.sh";
+
+// Bare project roots don't have their own index page and should redirect to
+// their actual landing page instead of 404ing.
+export const PROJECT_LANDING_PAGES: Record = {
+ clack: "/docs/clack/basics/getting-started/",
+ tab: "/docs/tab/",
+ args: "/docs/args/getting-started/",
+ tty: "/docs/tty/basics/getting-started/",
+};
diff --git a/router/src/index.ts b/router/src/index.ts
index 937bb76..d66a9dc 100644
--- a/router/src/index.ts
+++ b/router/src/index.ts
@@ -1,123 +1,3 @@
-import { prefersMarkdown, toMarkdownPath } from "./negotiate";
+import { createApp } from "./app.ts";
-export interface Env { }
-
-// Public origin used in Link headers (canonical/alternate) so agents always
-// discover the proxied bomb.sh URLs, never the internal workers.dev ones.
-const SITE = "https://bomb.sh";
-
-const MARKDOWN_404 = `# 404: Not Found
-
-This page does not exist. An index of all Bombshell documentation is
-available at ${SITE}/docs/index.md
-`;
-
-// Where to proxy docs requests. In production this is the live site. On
-// Cloudflare branch previews both Workers share the same branch slug
-// (e.g. `fix-404-bombsh-docs-router` ↔ `fix-404-bombshell-docs`), so we point
-// the router at the matching docs preview by rewriting our own hostname.
-// Per-version previews use an 8-char hex id that differs per Worker and can't
-// be mapped, so those fall back to production.
-function docsOrigin(host: string): string {
- const match = host.match(/^(.+)-bombsh-docs-router\.(.+\.workers\.dev)$/);
- if (match) {
- const [, slug, zone] = match;
- if (!/^[0-9a-f]{8}$/.test(slug)) {
- return `https://${slug}-bombshell-docs.${zone}/`;
- }
- }
- return "https://docs.bomb.sh/";
-}
-
-// Bare project roots don't have their own index page and should redirect to
-// their actual landing page instead of 404ing.
-const PROJECT_LANDING_PAGES: Record = {
- clack: "/docs/clack/basics/getting-started/",
- tab: "/docs/tab/",
- args: "/docs/args/getting-started/",
- tty: "/docs/tty/basics/getting-started/",
-};
-
-export default {
- async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
- const url = new URL(request.url);
-
- const projectMatch = url.pathname.match(/^\/docs\/([^/]+)\/?$/);
- if (projectMatch) {
- const landingPage = PROJECT_LANDING_PAGES[projectMatch[1]];
- if (landingPage && url.pathname !== landingPage) {
- return Response.redirect(new URL(landingPage, url).toString(), 308);
- }
- }
-
- if (url.pathname.startsWith("/docs")) {
- const origin = docsOrigin(url.host);
-
- // Agent-facing markdown: explicit `.md` paths always serve markdown;
- // extensionless page routes negotiate on `Accept: text/markdown`.
- // Negotiation rewrites to a distinct origin URL, so HTML and markdown
- // variants get distinct cache keys — Cloudflare's cache ignores `Vary`.
- const markdownPath = toMarkdownPath(url.pathname);
- const wantsMarkdown =
- markdownPath !== null &&
- (url.pathname.endsWith(".md") ||
- prefersMarkdown(request.headers.get("Accept")));
-
- if (wantsMarkdown && markdownPath) {
- const response = await fetch(new URL(markdownPath, origin));
- if (response.status === 404) {
- return new Response(MARKDOWN_404, {
- status: 404,
- headers: {
- "Content-Type": "text/markdown; charset=utf-8",
- "Vary": "Accept",
- },
- });
- }
- const headers = new Headers(response.headers);
- headers.set("Content-Type", "text/markdown; charset=utf-8");
- headers.set("Vary", "Accept");
- const htmlPath = markdownPath.slice(0, -"index.md".length);
- headers.set("Link", `<${SITE}${htmlPath}>; rel="canonical"`);
- return new Response(response.body, {
- status: response.status,
- headers,
- });
- }
-
- let response = await fetch(new URL(url.pathname, docsOrigin(url.host)));
- console.log({ from: url, to: new URL(url.pathname, docsOrigin(url.host)) });
-
-
- // Special case for Starlight's 404 page
- let status = response.status;
- if (status === 404) {
- response = await fetch(new URL("/docs/404.html", origin))
- }
-
- const headers = new Headers(response.headers);
- headers.set("Cross-Origin-Embedder-Policy", "require-corp");
- headers.set("Cross-Origin-Opener-Policy", "same-origin");
- headers.set("Cross-Origin-Resource-Policy", "cross-origin");
- headers.set("Referrer-Policy", "strict-origin-when-cross-origin");
-
- // Advertise the markdown twin to agents crawling the HTML variant.
- if (markdownPath && status === 200) {
- headers.set(
- "Link",
- `<${SITE}${markdownPath}>; rel="alternate"; type="text/markdown"`,
- );
- }
-
- // If we got 404, return the HTML, but set status to 404 manually,
- // because the response status would be 200
- return new Response(response.body, {
- status: status,
- statusText: status === 404 ? "Not Found" : response.statusText,
- headers,
- });
- }
-
- return fetch(request);
- },
-} satisfies ExportedHandler;
+export default createApp();
diff --git a/router/src/markdown.ts b/router/src/markdown.ts
new file mode 100644
index 0000000..15d3e1b
--- /dev/null
+++ b/router/src/markdown.ts
@@ -0,0 +1,50 @@
+import type { MiddlewareHandler } from "hono";
+import { prefersMarkdown, toMarkdownPath } from "./negotiate.ts";
+import { docsOrigin } from "./origin.ts";
+import { SITE } from "./config.ts";
+import type { Upstream } from "./upstream.ts";
+
+export const MARKDOWN_404 = `# 404: Not Found
+
+This page does not exist. An index of all Bombshell documentation is
+available at ${SITE}/docs/index.md
+`;
+
+// Agent-facing markdown: explicit `.md` paths always serve markdown;
+// extensionless page routes negotiate on `Accept: text/markdown`.
+// Negotiation rewrites to a distinct origin URL, so HTML and markdown
+// variants get distinct cache keys — Cloudflare's cache ignores `Vary`.
+export function markdownNegotiation(upstream: Upstream): MiddlewareHandler {
+ return async (c, next) => {
+ const url = new URL(c.req.url);
+ const markdownPath = toMarkdownPath(url.pathname);
+ const wantsMarkdown =
+ markdownPath !== null &&
+ (url.pathname.endsWith(".md") ||
+ prefersMarkdown(c.req.header("Accept") ?? null));
+ if (!wantsMarkdown || !markdownPath) return next();
+
+ const response = await upstream(
+ new URL(markdownPath, docsOrigin(url.host)),
+ );
+ if (response.status === 404) {
+ return new Response(MARKDOWN_404, {
+ status: 404,
+ headers: {
+ "Content-Type": "text/markdown; charset=utf-8",
+ Vary: "Accept",
+ },
+ });
+ }
+
+ const headers = new Headers(response.headers);
+ headers.set("Content-Type", "text/markdown; charset=utf-8");
+ headers.set("Vary", "Accept");
+ const htmlPath = markdownPath.slice(0, -"index.md".length);
+ headers.set("Link", `<${SITE}${htmlPath}>; rel="canonical"`);
+ return new Response(response.body, {
+ status: response.status,
+ headers,
+ });
+ };
+}
diff --git a/router/src/origin.test.ts b/router/src/origin.test.ts
new file mode 100644
index 0000000..e4c7fa9
--- /dev/null
+++ b/router/src/origin.test.ts
@@ -0,0 +1,22 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { docsOrigin } from "./origin.ts";
+
+test("falls back to production for plain hosts", () => {
+ assert.equal(docsOrigin("bomb.sh"), "https://docs.bomb.sh/");
+ assert.equal(docsOrigin("localhost:8787"), "https://docs.bomb.sh/");
+});
+
+test("maps branch preview router hosts to matching docs previews", () => {
+ assert.equal(
+ docsOrigin("fix-404-bombsh-docs-router.foo.workers.dev"),
+ "https://fix-404-bombshell-docs.foo.workers.dev/",
+ );
+});
+
+test("per-version preview ids fall back to production", () => {
+ assert.equal(
+ docsOrigin("abcd1234-bombsh-docs-router.foo.workers.dev"),
+ "https://docs.bomb.sh/",
+ );
+});
diff --git a/router/src/origin.ts b/router/src/origin.ts
new file mode 100644
index 0000000..5c03a5a
--- /dev/null
+++ b/router/src/origin.ts
@@ -0,0 +1,16 @@
+// Where to proxy docs requests. In production this is the live site. On
+// Cloudflare branch previews both Workers share the same branch slug
+// (e.g. `fix-404-bombsh-docs-router` ↔ `fix-404-bombshell-docs`), so we point
+// the router at the matching docs preview by rewriting our own hostname.
+// Per-version previews use an 8-char hex id that differs per Worker and can't
+// be mapped, so those fall back to production.
+export function docsOrigin(host: string): string {
+ const match = host.match(/^(.+)-bombsh-docs-router\.(.+\.workers\.dev)$/);
+ if (match) {
+ const [, slug, zone] = match;
+ if (!/^[0-9a-f]{8}$/.test(slug)) {
+ return `https://${slug}-bombshell-docs.${zone}/`;
+ }
+ }
+ return "https://docs.bomb.sh/";
+}
diff --git a/router/src/proxy.ts b/router/src/proxy.ts
new file mode 100644
index 0000000..850af3f
--- /dev/null
+++ b/router/src/proxy.ts
@@ -0,0 +1,47 @@
+import type { Handler } from "hono";
+import { toMarkdownPath } from "./negotiate.ts";
+import { docsOrigin } from "./origin.ts";
+import { SITE } from "./config.ts";
+import type { Upstream } from "./upstream.ts";
+
+const SECURITY_HEADERS = {
+ "Cross-Origin-Embedder-Policy": "require-corp",
+ "Cross-Origin-Opener-Policy": "same-origin",
+ "Cross-Origin-Resource-Policy": "cross-origin",
+ "Referrer-Policy": "strict-origin-when-cross-origin",
+} as const;
+
+export function docsProxy(upstream: Upstream): Handler {
+ return async (c) => {
+ const url = new URL(c.req.url);
+ const origin = docsOrigin(url.host);
+ let response = await upstream(new URL(url.pathname, origin));
+
+ // Starlight serves its 404 page at a fixed path with status 200;
+ // re-serve it under the requested URL with the right status.
+ const status = response.status;
+ if (status === 404) {
+ response = await upstream(new URL("/docs/404.html", origin));
+ }
+
+ const headers = new Headers(response.headers);
+ for (const [name, value] of Object.entries(SECURITY_HEADERS)) {
+ headers.set(name, value);
+ }
+
+ // Advertise the markdown twin to agents crawling the HTML variant.
+ const markdownPath = toMarkdownPath(url.pathname);
+ if (markdownPath && status === 200) {
+ headers.set(
+ "Link",
+ `<${SITE}${markdownPath}>; rel="alternate"; type="text/markdown"`,
+ );
+ }
+
+ return new Response(response.body, {
+ status,
+ statusText: status === 404 ? "Not Found" : response.statusText,
+ headers,
+ });
+ };
+}
diff --git a/router/src/redirects.ts b/router/src/redirects.ts
new file mode 100644
index 0000000..7f47da4
--- /dev/null
+++ b/router/src/redirects.ts
@@ -0,0 +1,14 @@
+import type { MiddlewareHandler } from "hono";
+import { PROJECT_LANDING_PAGES } from "./config.ts";
+
+export function projectRedirects(): MiddlewareHandler {
+ return async (c, next) => {
+ const url = new URL(c.req.url);
+ const match = url.pathname.match(/^\/docs\/([^/]+)\/?$/);
+ const landingPage = match && PROJECT_LANDING_PAGES[match[1]];
+ if (landingPage && url.pathname !== landingPage) {
+ return c.redirect(new URL(landingPage, url).toString(), 308);
+ }
+ return next();
+ };
+}
diff --git a/router/src/upstream.ts b/router/src/upstream.ts
new file mode 100644
index 0000000..81d6a7b
--- /dev/null
+++ b/router/src/upstream.ts
@@ -0,0 +1,4 @@
+/** Fetches a URL from the docs assets Worker. Injectable so tests can script responses. */
+export type Upstream = (url: URL) => Promise;
+
+export const defaultUpstream: Upstream = (url) => fetch(url);
diff --git a/router/tsconfig.json b/router/tsconfig.json
index 911fc10..96d360d 100644
--- a/router/tsconfig.json
+++ b/router/tsconfig.json
@@ -3,6 +3,7 @@
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
+ "allowImportingTsExtensions": true,
"lib": ["ESNext"],
"types": ["@cloudflare/workers-types"],
"strict": true,