export type TechLogServingContract = Readonly<{ schemaVersion: 2; /** * Patterns, not an enumeration. * * This used to list every public path the bundled fixture happened to * contain, and the generated nginx served exactly those. A record published * after the build — which is the entire point of a backend — answered 404 at * the edge before the SPA was ever asked, and no amount of correct routing * inside the bundle could recover it. * * The route contract already declares which paths exist; the catalog only * decides which of them currently resolve, and that is the SPA's call, not * the web server's. Studio has been pattern-based all along — this brings the * public half to the same footing. */ publicSpaPathPatterns: readonly string[]; studioPathPrefix: "/studio"; studioSpaPathPatterns: readonly string[]; notFound: Readonly<{ status: 404; contentType: "text/plain;charset=UTF-8"; body: "Not Found"; }>; }>; type ServingContractInput = Readonly<{ /** * The public route templates the router registers, in route-contract form * (`/cases/:slug`). Passed in rather than imported so this module stays a * pure transform the tests can drive directly. */ publicRoutePaths: readonly string[]; /** The Studio route templates, same form and same reason. */ studioRoutePaths: readonly string[]; }>; /** * `/cases/:slug` -> `^/cases/[^/]+$`. A parameter matches one segment and never * a slash, which is what keeps `/cases/a/b` a 404 instead of a case page. */ function patternOf(routePath: string): string { const escaped = routePath .split("/") .map((segment) => segment.startsWith(":") ? "[^/]+" : segment.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), ) .join("/"); return `^${escaped === "" ? "/" : escaped}$`; } function asciiCompare(left: string, right: string): number { return left < right ? -1 : left > right ? 1 : 0; } /** * The catch-all is the SPA's own not-found screen; serving index.html for every * unmatched URL would turn the edge 404 into a soft 200 and hide broken links * from crawlers and from us. */ function patternsFor(routePaths: readonly string[]): readonly string[] { const patterns = new Set(); for (const routePath of routePaths) { if (routePath === "*" || routePath.includes("*")) continue; patterns.add(patternOf(routePath)); } return Object.freeze([...patterns].sort(asciiCompare)); } export function createTechLogServingContract({ publicRoutePaths, studioRoutePaths, }: ServingContractInput): TechLogServingContract { return Object.freeze({ schemaVersion: 2, publicSpaPathPatterns: patternsFor(publicRoutePaths), studioPathPrefix: "/studio", // Derived, not listed. This was a hand-maintained array, and it went stale // exactly the way a hand-maintained array does: /studio/assets was missing // for its whole life, and /studio/releases repeated the mistake the moment // it was added — the route worked by client-side navigation and 404'd on // reload, because nginx had never heard of it. The route contract already // knows which Studio paths exist, so ask it. studioSpaPathPatterns: patternsFor(studioRoutePaths), notFound: Object.freeze({ status: 404, contentType: "text/plain;charset=UTF-8", body: "Not Found", }), }); }