// c21 — handlers: the Bun.serve `fetch` fallback. Catches every request // the routes table can't express directly: regex-matched multi-segment // slugs (admin edit/delete, /p/), the /GIT browse tree, the // bare //.git redirect, the git smart/dumb-HTTP proxy, and // the bare // repo view. Extracted from c21_app.ts per the // SAMA Atomic rule. import { renderNotFound, htmlResponse, } from "./b51_render_layout.ts"; import { proxyToForgejo } from "./c14_forgejo.ts"; import { parseUrl } from "./c14_request_parse.ts"; import { getViewer } from "./b32_session.ts"; import { renderRepoView } from "./d21_handlers_repo_view.ts"; import { adminEditHandler, adminDeleteHandler, } from "./d21_handlers_admin.ts"; import { renderPublicPage } from "./d21_handlers_content.ts"; import { parseRepoBrowsePath, repoBrowseHandler, } from "./d21_handlers_repo_browse.ts"; import { rewriteOldGitUrl } from "./b32_git_url_redirect.ts"; import { rewriteOldSamaDisciplineUrl } from "./b32_sama_discipline_url_redirect.ts"; import { rewriteOldBlogUrl } from "./b32_blog_date_url_redirect.ts"; const isGitProtocol = (pathname: string, search: URLSearchParams): boolean => { if (pathname.includes(".git/") || pathname.endsWith(".git")) return true; if ( pathname.endsWith("/info/refs") && (search.get("service") === "git-upload-pack" || search.get("service") === "git-receive-pack") ) { return true; } if (pathname.endsWith("/git-upload-pack") || pathname.endsWith("/git-receive-pack")) { return true; } return false; }; export const appFetch = async (req: Request): Promise => { const urlR = parseUrl(req.url); // Bun.serve guarantees req.url is well-formed for routed requests; // if parseUrl somehow fails, fall through to a 404 via the default // notFound branch at the end of this function. if (!urlR.ok) { const html = await renderNotFound("/"); return htmlResponse(html, 404); } const url = urlR.value; // Static images under /images/.. Convention: every new // site image lives at public/images/ and is served from /images/. // The whitelist of extensions + the strict filename pattern blocks // path traversal (no slashes after /images/, no leading dots). const imagesMatch = url.pathname.match( /^\/images\/([A-Za-z0-9][A-Za-z0-9._-]*)\.(svg|png|webp|jpg|jpeg|gif)$/, ); if (imagesMatch) { const file = Bun.file(`./public/images/${imagesMatch[1]}.${imagesMatch[2]}`); if (await file.exists()) { const ext = imagesMatch[2]!; const contentType = ext === "svg" ? "image/svg+xml" : ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : "image/jpeg"; return new Response(file, { headers: { "Content-Type": contentType, "Cache-Control": "public, max-age=3600", }, }); } } // Admin edit/delete on multi-segment slugs (company/about, docs/spec/grammar // etc.). Bun's `:slug` param can't span "/" so anything with two-or-more // segments after the type slot ends up here. Single-segment is handled // by the routes table and never reaches this branch. const adminEditMulti = url.pathname.match( /^\/admin\/edit\/(page|post)\/([a-z0-9_\-/]+?)\/?$/, ); if (adminEditMulti) { const reqP = Object.assign(req, { params: { type: adminEditMulti[1]!, slug: adminEditMulti[2]! }, }); return adminEditHandler(reqP); } const adminDeleteMulti = url.pathname.match( /^\/admin\/delete\/(page|post)\/([a-z0-9_\-/]+?)\/?$/, ); if (adminDeleteMulti) { const reqP = Object.assign(req, { params: { type: adminDeleteMulti[1]!, slug: adminDeleteMulti[2]! }, }); return adminDeleteHandler(reqP); } // Public sxdoc-backed pages on multi-segment slugs (e.g. // /p/company/about, /p/docs/spec/grammar). Single-segment goes through // the explicit `/p/:slug` route on Bun.serve. const publicPageMulti = url.pathname.match(/^\/p\/([a-z0-9_\-/]+?)\/?$/); if (publicPageMulti) { return renderPublicPage(publicPageMulti[1]!); } // Bare //.git (no sub-path) is what someone gets when // they paste the clone URL into a browser. Without intervention our // proxy hands it to Forgejo, whose chrome then leaks onto tdd.md. // Redirect to the clean URL so the visitor lands on the Bun-native // scoreboard. Real git operations always have sub-paths // (/info/refs, /git-upload-pack, /objects/...) and continue to be // proxied below. const bareGitUrl = url.pathname.match( /^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\.git\/?$/, ); if (bareGitUrl) { return new Response(null, { status: 302, headers: { Location: `/${bareGitUrl[1]}/${bareGitUrl[2]}` }, }); } // Legacy /GIT/syntaxai/tdd.md/ URLs permanent-redirect to // the new owner-less shape. MUST sit before the browse-match below // so the legacy URL never reaches the browse handler. One regex // covers every kind (tree/blob/raw/commit) + every future path. const newGitPath = rewriteOldGitUrl(url.pathname); if (newGitPath !== null) { return new Response(null, { status: 301, headers: { Location: newGitPath, "Cache-Control": "public, max-age=86400", }, }); } // Legacy /sama/ URLs permanent-redirect to the new // /sama/discipline/ namespace. Same pattern-as-redirect shape // as the /GIT/ block above — pure Layer-1 transform + Layer-3 // Response wrapper. Hypothesis-test instance of the pattern's // reusability (see /blog/2026-05/sama-v2-git-url-refactor-postmortem). const newSamaPath = rewriteOldSamaDisciplineUrl(url.pathname); if (newSamaPath !== null) { return new Response(null, { status: 301, headers: { Location: newSamaPath, "Cache-Control": "public, max-age=86400", }, }); } // Legacy /blog/ URLs permanent-redirect to /blog//. // Third instance of the pattern, first DATA-DRIVEN one — helper looks // up the post's date in ALL_POSTS to build the new prefix. const newBlogPath = rewriteOldBlogUrl(url.pathname); if (newBlogPath !== null) { return new Response(null, { status: 301, headers: { Location: newBlogPath, "Cache-Control": "public, max-age=86400", }, }); } // SAMA-native repo browse at /GIT/:repo/{tree,blob,raw}/:ref/. // The wildcard path needs more flexibility than Bun's :param routes // give us (no slashes), so we match in the fallback fetch instead. const gitBrowseMatch = url.pathname.match( /^\/GIT\/([A-Za-z0-9][A-Za-z0-9._-]+)\/(.+)$/, ); if (gitBrowseMatch) { const repo = gitBrowseMatch[1]!; const suffix = gitBrowseMatch[2]!; // Skip the commit/ shape — that's c21_handlers_commit_view's // turf and lives as an explicit Bun.serve route in c21_app. if (!suffix.startsWith("commit/")) { const target = parseRepoBrowsePath(suffix); if (target !== null) { return repoBrowseHandler(req, repo, target); } } } // Git smart-HTTP and dumb-HTTP — proxy raw to Forgejo. if (isGitProtocol(url.pathname, url.searchParams)) { return proxyToForgejo(req, url.pathname + url.search); } // Bare repo URL: // — render Bun-native view via Forgejo API. // Two segments only, no trailing path. Reserved top-level paths are // already matched by explicit routes in c21_app and never reach here. const repoMatch = url.pathname.match(/^\/([A-Za-z0-9][A-Za-z0-9-]*)\/([A-Za-z0-9][A-Za-z0-9._-]*)\/?$/); if (repoMatch) { const viewer = await getViewer(req); return renderRepoView(repoMatch[1]!, repoMatch[2]!, viewer); } const html = await renderNotFound(url.pathname); return htmlResponse(html, 404); }; export const appError = (err: Error): Response => { console.error(err); return new Response("internal error", { status: 500 }); };