From 83001f6f6c637ba7756eb8871f0c9aa6a5eec910 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sat, 15 Aug 2026 00:07:45 -0400 Subject: [PATCH 1/2] chore(router): add hono --- pnpm-lock.yaml | 10 ++++++++++ router/package.json | 3 +++ 2 files changed, 13 insertions(+) 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" } } From f56f7b29427cf9fbd95105d8119030a06abc6e72 Mon Sep 17 00:00:00 2001 From: Nate Moore Date: Sat, 15 Aug 2026 00:07:45 -0400 Subject: [PATCH 2/2] refactor(router): compose the worker from modular middleware --- router/src/app.test.ts | 135 ++++++++++++++++++++++++++++++++++++++ router/src/app.ts | 27 ++++++++ router/src/config.ts | 12 ++++ router/src/index.ts | 124 +--------------------------------- router/src/markdown.ts | 50 ++++++++++++++ router/src/origin.test.ts | 22 +++++++ router/src/origin.ts | 16 +++++ router/src/proxy.ts | 47 +++++++++++++ router/src/redirects.ts | 14 ++++ router/src/upstream.ts | 4 ++ router/tsconfig.json | 1 + 11 files changed, 330 insertions(+), 122 deletions(-) create mode 100644 router/src/app.test.ts create mode 100644 router/src/app.ts create mode 100644 router/src/config.ts create mode 100644 router/src/markdown.ts create mode 100644 router/src/origin.test.ts create mode 100644 router/src/origin.ts create mode 100644 router/src/proxy.ts create mode 100644 router/src/redirects.ts create mode 100644 router/src/upstream.ts 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,