syntaxai/tdd.md · main · src / d21_handlers_fallback.ts
// 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/<deep/slug>), the /GIT browse tree, the
// bare /<owner>/<repo>.git redirect, the git smart/dumb-HTTP proxy, and
// the bare /<owner>/<repo> 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<Response> => {
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/<name>.<ext>. 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 /<owner>/<repo>.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/<suffix> 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/<discipline> URLs permanent-redirect to the new
// /sama/discipline/<slug> 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/<slug> URLs permanent-redirect to /blog/<yyyy-mm>/<slug>.
// 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/<path>.
// 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/<sha> 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: /<owner>/<repo> — 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 });
};