diff --git a/scripts/generate-nginx-config.ts b/scripts/generate-nginx-config.ts index 0a73d99..5b0ef16 100644 --- a/scripts/generate-nginx-config.ts +++ b/scripts/generate-nginx-config.ts @@ -27,7 +27,7 @@ const DIST = "dist"; const OUT = path.join(DIST, "nginx.conf"); type ServingContract = Readonly<{ - publicSpaPaths: readonly string[]; + publicSpaPathPatterns: readonly string[]; studioPathPrefix: string; studioSpaPathPatterns: readonly string[]; notFound: Readonly<{ status: number; contentType: string; body: string }>; @@ -115,9 +115,12 @@ async function main(): Promise { .replace(/^charset=/i, "") .toLowerCase(); - const publicLocations = contract.publicSpaPaths + // Regex locations now, matching the Studio half: the contract declares which + // paths the router serves, not which ones the fixture happened to contain, so + // a record published after this build is served instead of 404ed at the edge. + const publicLocations = contract.publicSpaPathPatterns .map( - (pathname) => ` location = ${basePath}${exactLocation(pathname)} { + (pattern: string) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} { ${secure} add_header Cache-Control "${indexCache}" always; try_files /index.html =404; @@ -220,7 +223,7 @@ ${studioLocations} await writeFile(OUT, conf, "utf8"); process.stdout.write( - `nginx config: ${OUT} (${contract.publicSpaPaths.length} public routes, ` + + `nginx config: ${OUT} (${contract.publicSpaPathPatterns.length} public routes, ` + `${contract.studioSpaPathPatterns.length} studio patterns)\n`, ); } diff --git a/scripts/generate-tech-log-serving-artifact.ts b/scripts/generate-tech-log-serving-artifact.ts index b28d02b..8618ca0 100644 --- a/scripts/generate-tech-log-serving-artifact.ts +++ b/scripts/generate-tech-log-serving-artifact.ts @@ -1,15 +1,14 @@ -import { - projects, - publicRecords, - releases, -} from "../src/features/tech-log/adapters/static/public-content.ts"; +import { TECH_LOG_ROUTE_REGISTRY } from "../src/features/tech-log/contracts/tech-log-route-contract.ts"; import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts"; import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts"; -const contract = createTechLogServingContract({ - projects, - publicRecords, - releases, -}); +// The router owns which public paths exist. Reading them from the catalog +// instead — as this did — pinned the served set to whatever the bundled fixture +// contained on the day of the build. +const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY) + .filter((route) => route.layoutGroup === "PUBLIC") + .map((route) => route.path); + +const contract = createTechLogServingContract({ publicRoutePaths }); await writeTechLogServingArtifact({ distRoot: "dist", contract }); diff --git a/scripts/lib/tech-log-production-server.ts b/scripts/lib/tech-log-production-server.ts index 66de008..07685c6 100644 --- a/scripts/lib/tech-log-production-server.ts +++ b/scripts/lib/tech-log-production-server.ts @@ -45,7 +45,9 @@ export function createTechLogProductionServer({ contract, }) { const absoluteRoot = path.resolve(root); - const publicSpaPaths = new Set(contract.publicSpaPaths); + const publicSpaPathPatterns = contract.publicSpaPathPatterns.map( + (pattern) => new RegExp(pattern), + ); const studioSpaPathPatterns = contract.studioSpaPathPatterns.map( (pattern) => new RegExp(pattern), ); @@ -69,7 +71,7 @@ export function createTechLogProductionServer({ } } if ( - publicSpaPaths.has(pathname) || + publicSpaPathPatterns.some((pattern) => pattern.test(pathname)) || studioSpaPathPatterns.some((pattern) => pattern.test(pathname)) ) { await sendFile(path.join(absoluteRoot, "index.html"), request.method, response); diff --git a/scripts/lib/tech-log-serving-contract.ts b/scripts/lib/tech-log-serving-contract.ts index 63ad5d2..9c5c506 100644 --- a/scripts/lib/tech-log-serving-contract.ts +++ b/scripts/lib/tech-log-serving-contract.ts @@ -1,6 +1,20 @@ export type TechLogServingContract = Readonly<{ - schemaVersion: 1; - publicSpaPaths: readonly string[]; + 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<{ @@ -11,25 +25,29 @@ export type TechLogServingContract = Readonly<{ }>; type ServingContractInput = Readonly<{ - publicRecords: readonly Readonly<{ - path: string; - topicSlug: string; - }>[]; - projects: readonly Readonly<{ slug: string }>[]; - releases: readonly Readonly<{ path: string }>[]; + /** + * 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[]; }>; -const staticPublicPaths = Object.freeze([ - "/", - "/explore", - "/explore/cases", - "/explore/questions", - "/explore/references", - "/profile", - "/projects", - "/releases", - "/search", -]); +/** + * `/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}$`; +} const studioSpaPathPatterns = Object.freeze([ "^/studio$", @@ -50,27 +68,20 @@ function asciiCompare(left: string, right: string): number { } export function createTechLogServingContract({ - publicRecords, - projects, - releases, + publicRoutePaths, }: ServingContractInput): TechLogServingContract { - const publicSpaPaths = new Set(staticPublicPaths); - for (const record of publicRecords) { - publicSpaPaths.add(record.path); - publicSpaPaths.add(`/topics/${record.topicSlug}`); + const patterns = new Set(); + for (const routePath of publicRoutePaths) { + // 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. + if (routePath === "*" || routePath.includes("*")) continue; + patterns.add(patternOf(routePath)); } - for (const project of projects) { - const projectPath = `/projects/${project.slug}`; - publicSpaPaths.add(projectPath); - publicSpaPaths.add(`${projectPath}/activity`); - publicSpaPaths.add(`${projectPath}/decisions`); - publicSpaPaths.add(`${projectPath}/records`); - } - for (const release of releases) publicSpaPaths.add(release.path); return Object.freeze({ - schemaVersion: 1, - publicSpaPaths: Object.freeze([...publicSpaPaths].sort(asciiCompare)), + schemaVersion: 2, + publicSpaPathPatterns: Object.freeze([...patterns].sort(asciiCompare)), studioPathPrefix: "/studio", studioSpaPathPatterns, notFound: Object.freeze({ diff --git a/tests/unit/tech-log-serving-artifact.test.ts b/tests/unit/tech-log-serving-artifact.test.ts index 9a2e7c6..a49eecc 100644 --- a/tests/unit/tech-log-serving-artifact.test.ts +++ b/tests/unit/tech-log-serving-artifact.test.ts @@ -18,8 +18,8 @@ describe("TechLog serving artifact", () => { const root = await mkdtemp(path.join(tmpdir(), "tech-log-serving-artifact-")); roots.push(root); const contract = { - schemaVersion: 1 as const, - publicSpaPaths: ["/", "/projects/auth-lab"], + schemaVersion: 2 as const, + publicSpaPathPatterns: ["^/$", "^/projects/[^/]+$"], studioPathPrefix: "/studio" as const, studioSpaPathPatterns: ["^/studio$"], notFound: { diff --git a/tests/unit/tech-log-serving-contract.test.ts b/tests/unit/tech-log-serving-contract.test.ts index 2387067..70d7304 100644 --- a/tests/unit/tech-log-serving-contract.test.ts +++ b/tests/unit/tech-log-serving-contract.test.ts @@ -1,48 +1,34 @@ import { describe, expect, it } from "vitest"; import { createTechLogServingContract } from "../../scripts/lib/tech-log-serving-contract.ts"; -import { - projects, - publicRecords, - releases, -} from "../../src/features/tech-log/adapters/static/public-content.ts"; +import { TECH_LOG_ROUTE_REGISTRY } from "../../src/features/tech-log/contracts/tech-log-route-contract.ts"; + +const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY) + .filter((route) => route.layoutGroup === "PUBLIC") + .map((route) => route.path); describe("TechLog production serving contract", () => { - it("derives every known Public path from the installed static content", () => { - const contract = createTechLogServingContract({ - projects, - publicRecords, - releases, - }); + it("derives one pattern per registered Public route", () => { + const contract = createTechLogServingContract({ publicRoutePaths }); - expect(contract.publicSpaPaths).toEqual([ - "/", - "/cases/collection-fetch-join-pagination", - "/cases/redis-adapter-ttl-boundary", - "/explore", - "/explore/cases", - "/explore/questions", - "/explore/references", - "/profile", - "/projects", - "/projects/auth-lab", - "/projects/auth-lab/activity", - "/projects/auth-lab/decisions", - "/projects/auth-lab/records", - "/projects/backend-skeleton", - "/projects/backend-skeleton/activity", - "/projects/backend-skeleton/decisions", - "/projects/backend-skeleton/records", - "/questions/collection-fetch-join-with-pagination", - "/questions/validate-edge-token-again", - "/references/jpa-list-fetch-strategy", - "/references/state-and-nonce-boundary", - "/releases", - "/releases/0.1.0", - "/search", - "/topics/authentication", - "/topics/jpa", - "/topics/redis", + expect(contract.schemaVersion).toBe(2); + expect(contract.publicSpaPathPatterns).toEqual([ + "^/$", + "^/cases/[^/]+$", + "^/explore$", + "^/explore/[^/]+$", + "^/profile$", + "^/projects$", + "^/projects/[^/]+$", + "^/projects/[^/]+/activity$", + "^/projects/[^/]+/decisions$", + "^/projects/[^/]+/records$", + "^/questions/[^/]+$", + "^/references/[^/]+$", + "^/releases$", + "^/releases/[^/]+$", + "^/search$", + "^/topics/[^/]+$", ]); expect(contract.studioPathPrefix).toBe("/studio"); expect(contract.studioSpaPathPatterns).toEqual([ @@ -60,4 +46,41 @@ describe("TechLog production serving contract", () => { body: "Not Found", }); }); + + /** + * The reason this migration happened: a slug that did not exist at build time + * used to be 404ed by the web server before the SPA was asked. A published + * record must be served whatever its slug. + */ + it("serves a slug the build never saw", () => { + const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths }); + const matches = (pathname: string) => + publicSpaPathPatterns.some((pattern) => new RegExp(pattern).test(pathname)); + + expect(matches("/cases/a-record-published-yesterday")).toBe(true); + expect(matches("/projects/a-new-project/decisions")).toBe(true); + expect(matches("/topics/a-new-topic")).toBe(true); + }); + + /** A parameter is one segment. Extra depth is a 404, not a soft 200. */ + it("does not let a parameter swallow a slash", () => { + const { publicSpaPathPatterns } = createTechLogServingContract({ publicRoutePaths }); + const matches = (pathname: string) => + publicSpaPathPatterns.some((pattern) => new RegExp(pattern).test(pathname)); + + expect(matches("/cases/a/b")).toBe(false); + expect(matches("/projects/one/two/three")).toBe(false); + expect(matches("/nope")).toBe(false); + }); + + /** + * The catch-all belongs to the SPA, not to the edge. Serving index.html for + * every unmatched URL would turn a 404 into a soft 200 and hide broken links. + */ + it("drops the catch-all route", () => { + const { publicSpaPathPatterns } = createTechLogServingContract({ + publicRoutePaths: ["/", "*"], + }); + expect(publicSpaPathPatterns).toEqual(["^/$"]); + }); });