fix: serve the public routes the router declares, not the slugs the build saw

The serving contract enumerated every public path the bundled fixture happened
to contain, and the generated nginx published exactly those as `location =`
blocks. A record published after the build — the entire point of having 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. Twenty-seven frozen
paths, and any twenty-eighth was unreachable.

The route contract already declares which paths exist; the catalog only decides
which of them currently resolve, and that is the SPA's call rather than the web
server's. So the contract now emits one regex per registered Public route,
derived from the router, the way the Studio half has always worked.

A parameter matches one segment and never a slash, so /cases/a/b stays a 404
instead of quietly rendering a case page. The catch-all route is dropped rather
than translated: serving index.html for every unmatched URL would turn an edge
404 into a soft 200 and hide broken links from crawlers and from us.

Verified against a built image: /cases/a-brand-new-slug now answers 200 while
/nope and /cases/a/b still answer 404.

schemaVersion goes to 2 because the field changed shape, not just contents —
a consumer reading publicSpaPaths would otherwise see an absent key rather than
a version it can refuse.
This commit is contained in:
DongHyeonka
2026-08-20 17:48:39 +09:00
parent 24c01aedf2
commit 6784eb1ce6
6 changed files with 131 additions and 93 deletions
+7 -4
View File
@@ -27,7 +27,7 @@ const DIST = "dist";
const OUT = path.join(DIST, "nginx.conf"); const OUT = path.join(DIST, "nginx.conf");
type ServingContract = Readonly<{ type ServingContract = Readonly<{
publicSpaPaths: readonly string[]; publicSpaPathPatterns: readonly string[];
studioPathPrefix: string; studioPathPrefix: string;
studioSpaPathPatterns: readonly string[]; studioSpaPathPatterns: readonly string[];
notFound: Readonly<{ status: number; contentType: string; body: string }>; notFound: Readonly<{ status: number; contentType: string; body: string }>;
@@ -115,9 +115,12 @@ async function main(): Promise<void> {
.replace(/^charset=/i, "") .replace(/^charset=/i, "")
.toLowerCase(); .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( .map(
(pathname) => ` location = ${basePath}${exactLocation(pathname)} { (pattern: string) => ` location ~ ^${basePath}${studioRegex(pattern).slice(1)} {
${secure} ${secure}
add_header Cache-Control "${indexCache}" always; add_header Cache-Control "${indexCache}" always;
try_files /index.html =404; try_files /index.html =404;
@@ -220,7 +223,7 @@ ${studioLocations}
await writeFile(OUT, conf, "utf8"); await writeFile(OUT, conf, "utf8");
process.stdout.write( 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`, `${contract.studioSpaPathPatterns.length} studio patterns)\n`,
); );
} }
+9 -10
View File
@@ -1,15 +1,14 @@
import { import { TECH_LOG_ROUTE_REGISTRY } from "../src/features/tech-log/contracts/tech-log-route-contract.ts";
projects,
publicRecords,
releases,
} from "../src/features/tech-log/adapters/static/public-content.ts";
import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts"; import { writeTechLogServingArtifact } from "./lib/tech-log-serving-artifact.ts";
import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts"; import { createTechLogServingContract } from "./lib/tech-log-serving-contract.ts";
const contract = createTechLogServingContract({ // The router owns which public paths exist. Reading them from the catalog
projects, // instead — as this did — pinned the served set to whatever the bundled fixture
publicRecords, // contained on the day of the build.
releases, 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 }); await writeTechLogServingArtifact({ distRoot: "dist", contract });
+4 -2
View File
@@ -45,7 +45,9 @@ export function createTechLogProductionServer({
contract, contract,
}) { }) {
const absoluteRoot = path.resolve(root); 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( const studioSpaPathPatterns = contract.studioSpaPathPatterns.map(
(pattern) => new RegExp(pattern), (pattern) => new RegExp(pattern),
); );
@@ -69,7 +71,7 @@ export function createTechLogProductionServer({
} }
} }
if ( if (
publicSpaPaths.has(pathname) || publicSpaPathPatterns.some((pattern) => pattern.test(pathname)) ||
studioSpaPathPatterns.some((pattern) => pattern.test(pathname)) studioSpaPathPatterns.some((pattern) => pattern.test(pathname))
) { ) {
await sendFile(path.join(absoluteRoot, "index.html"), request.method, response); await sendFile(path.join(absoluteRoot, "index.html"), request.method, response);
+47 -36
View File
@@ -1,6 +1,20 @@
export type TechLogServingContract = Readonly<{ export type TechLogServingContract = Readonly<{
schemaVersion: 1; schemaVersion: 2;
publicSpaPaths: readonly string[]; /**
* 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"; studioPathPrefix: "/studio";
studioSpaPathPatterns: readonly string[]; studioSpaPathPatterns: readonly string[];
notFound: Readonly<{ notFound: Readonly<{
@@ -11,25 +25,29 @@ export type TechLogServingContract = Readonly<{
}>; }>;
type ServingContractInput = Readonly<{ type ServingContractInput = Readonly<{
publicRecords: readonly Readonly<{ /**
path: string; * The public route templates the router registers, in route-contract form
topicSlug: string; * (`/cases/:slug`). Passed in rather than imported so this module stays a
}>[]; * pure transform the tests can drive directly.
projects: readonly Readonly<{ slug: string }>[]; */
releases: readonly Readonly<{ path: string }>[]; publicRoutePaths: readonly string[];
}>; }>;
const staticPublicPaths = Object.freeze([ /**
"/", * `/cases/:slug` -> `^/cases/[^/]+$`. A parameter matches one segment and never
"/explore", * a slash, which is what keeps `/cases/a/b` a 404 instead of a case page.
"/explore/cases", */
"/explore/questions", function patternOf(routePath: string): string {
"/explore/references", const escaped = routePath
"/profile", .split("/")
"/projects", .map((segment) =>
"/releases", segment.startsWith(":")
"/search", ? "[^/]+"
]); : segment.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"),
)
.join("/");
return `^${escaped === "" ? "/" : escaped}$`;
}
const studioSpaPathPatterns = Object.freeze([ const studioSpaPathPatterns = Object.freeze([
"^/studio$", "^/studio$",
@@ -50,27 +68,20 @@ function asciiCompare(left: string, right: string): number {
} }
export function createTechLogServingContract({ export function createTechLogServingContract({
publicRecords, publicRoutePaths,
projects,
releases,
}: ServingContractInput): TechLogServingContract { }: ServingContractInput): TechLogServingContract {
const publicSpaPaths = new Set(staticPublicPaths); const patterns = new Set<string>();
for (const record of publicRecords) { for (const routePath of publicRoutePaths) {
publicSpaPaths.add(record.path); // The catch-all is the SPA's own not-found screen; serving index.html for
publicSpaPaths.add(`/topics/${record.topicSlug}`); // 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({ return Object.freeze({
schemaVersion: 1, schemaVersion: 2,
publicSpaPaths: Object.freeze([...publicSpaPaths].sort(asciiCompare)), publicSpaPathPatterns: Object.freeze([...patterns].sort(asciiCompare)),
studioPathPrefix: "/studio", studioPathPrefix: "/studio",
studioSpaPathPatterns, studioSpaPathPatterns,
notFound: Object.freeze({ notFound: Object.freeze({
+2 -2
View File
@@ -18,8 +18,8 @@ describe("TechLog serving artifact", () => {
const root = await mkdtemp(path.join(tmpdir(), "tech-log-serving-artifact-")); const root = await mkdtemp(path.join(tmpdir(), "tech-log-serving-artifact-"));
roots.push(root); roots.push(root);
const contract = { const contract = {
schemaVersion: 1 as const, schemaVersion: 2 as const,
publicSpaPaths: ["/", "/projects/auth-lab"], publicSpaPathPatterns: ["^/$", "^/projects/[^/]+$"],
studioPathPrefix: "/studio" as const, studioPathPrefix: "/studio" as const,
studioSpaPathPatterns: ["^/studio$"], studioSpaPathPatterns: ["^/studio$"],
notFound: { notFound: {
+62 -39
View File
@@ -1,48 +1,34 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import { createTechLogServingContract } from "../../scripts/lib/tech-log-serving-contract.ts"; import { createTechLogServingContract } from "../../scripts/lib/tech-log-serving-contract.ts";
import { import { TECH_LOG_ROUTE_REGISTRY } from "../../src/features/tech-log/contracts/tech-log-route-contract.ts";
projects,
publicRecords, const publicRoutePaths = Object.values(TECH_LOG_ROUTE_REGISTRY)
releases, .filter((route) => route.layoutGroup === "PUBLIC")
} from "../../src/features/tech-log/adapters/static/public-content.ts"; .map((route) => route.path);
describe("TechLog production serving contract", () => { describe("TechLog production serving contract", () => {
it("derives every known Public path from the installed static content", () => { it("derives one pattern per registered Public route", () => {
const contract = createTechLogServingContract({ const contract = createTechLogServingContract({ publicRoutePaths });
projects,
publicRecords,
releases,
});
expect(contract.publicSpaPaths).toEqual([ expect(contract.schemaVersion).toBe(2);
"/", expect(contract.publicSpaPathPatterns).toEqual([
"/cases/collection-fetch-join-pagination", "^/$",
"/cases/redis-adapter-ttl-boundary", "^/cases/[^/]+$",
"/explore", "^/explore$",
"/explore/cases", "^/explore/[^/]+$",
"/explore/questions", "^/profile$",
"/explore/references", "^/projects$",
"/profile", "^/projects/[^/]+$",
"/projects", "^/projects/[^/]+/activity$",
"/projects/auth-lab", "^/projects/[^/]+/decisions$",
"/projects/auth-lab/activity", "^/projects/[^/]+/records$",
"/projects/auth-lab/decisions", "^/questions/[^/]+$",
"/projects/auth-lab/records", "^/references/[^/]+$",
"/projects/backend-skeleton", "^/releases$",
"/projects/backend-skeleton/activity", "^/releases/[^/]+$",
"/projects/backend-skeleton/decisions", "^/search$",
"/projects/backend-skeleton/records", "^/topics/[^/]+$",
"/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.studioPathPrefix).toBe("/studio"); expect(contract.studioPathPrefix).toBe("/studio");
expect(contract.studioSpaPathPatterns).toEqual([ expect(contract.studioSpaPathPatterns).toEqual([
@@ -60,4 +46,41 @@ describe("TechLog production serving contract", () => {
body: "Not Found", 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(["^/$"]);
});
}); });