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.
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
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[];
|
|
}>;
|
|
|
|
/**
|
|
* `/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$",
|
|
// The Asset Library is a first-class Studio route (TECH_LOG_STUDIO_ASSETS in
|
|
// the route contract) but was never listed here, so a hard navigation or a
|
|
// reload of /studio/assets was served the in-shell Studio 404 -- the screen
|
|
// was only reachable by client-side navigation from another Studio page.
|
|
"^/studio/assets$",
|
|
"^/studio/documents$",
|
|
"^/studio/documents/new$",
|
|
"^/studio/documents/[^/]+/(edit|validation|preview|publish)$",
|
|
"^/studio/publications$",
|
|
"^/studio/publications/[^/]+/preview$",
|
|
]);
|
|
|
|
function asciiCompare(left: string, right: string): number {
|
|
return left < right ? -1 : left > right ? 1 : 0;
|
|
}
|
|
|
|
export function createTechLogServingContract({
|
|
publicRoutePaths,
|
|
}: ServingContractInput): TechLogServingContract {
|
|
const patterns = new Set<string>();
|
|
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));
|
|
}
|
|
|
|
return Object.freeze({
|
|
schemaVersion: 2,
|
|
publicSpaPathPatterns: Object.freeze([...patterns].sort(asciiCompare)),
|
|
studioPathPrefix: "/studio",
|
|
studioSpaPathPatterns,
|
|
notFound: Object.freeze({
|
|
status: 404,
|
|
contentType: "text/plain;charset=UTF-8",
|
|
body: "Not Found",
|
|
}),
|
|
});
|
|
}
|