Skip to content

Commit 676f792

Browse files
committed
fix(hosting): coalesce SSG route fan-out to bound the edge router scan
The viewer-request CloudFront Function scans the route table sequentially per request; an unmatched/catch-all path (worst case: the site root) reads and JSON.parses every r{n} chunk before falling through. SSG sites emit one static route per prerendered page (Nuxt also emits a /<page>/* subtree per page), so a few hundred pages produced enough chunks to trip 'RangeError: Instruction limit exceeded' -- and since the function runs before any origin, EVERY route 503s (observed live: 100 prerendered pages took the Nuxt + Astro home pages down). Coalesce sibling routes that share a parent directory AND a single kind into one parent/* wildcard in buildKvsEntries, then dedupe identical rows (the bare + subtree forms collapse to the same wildcard). A deeper differently-kinded route keeps its own row and still sorts first (more literal segments). Root-level siblings are never folded into a site-wide /*. Verified live: Nuxt route table dropped rc=7 -> rc=1; root request-fn utilization 50 (ERROR) -> 13 (ok); all routes 200 on Nuxt + Astro.
1 parent 857d525 commit 676f792

2 files changed

Lines changed: 192 additions & 3 deletions

File tree

packages/hosting/src/constructs/kvs_router.test.ts

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { describe, it } from 'node:test';
1212
import assert from 'node:assert/strict';
1313
import {
1414
buildKvsEntries,
15+
coalesceRoutes,
1516
generateKvsRouterRequestCode,
1617
generateKvsRouterResponseCode,
1718
generateSentinelGuardCode,
@@ -861,3 +862,111 @@ void describe('buildKvsEntries — G17 image classification', () => {
861862
assert.ok(img && img[1] !== 'i', "must not be kind 'i' when no image origin");
862863
});
863864
});
865+
866+
void describe('coalesceRoutes — bound SSG fan-out for the edge scan', () => {
867+
void it('collapses many same-kind siblings under one parent into parent/*', () => {
868+
const rows: [string, 's'][] = Array.from({ length: 100 }, (_, i) => [
869+
`/stress/${i}/*`,
870+
's',
871+
]);
872+
const out = coalesceRoutes(rows);
873+
assert.equal(out.length, 1);
874+
assert.deepEqual(out[0], ['/stress/*', 's']);
875+
});
876+
877+
void it('also collapses the bare + subtree pair Nuxt emits per page', () => {
878+
// Nuxt emits BOTH `/stress/N` and `/stress/N/*` per prerendered page.
879+
const rows: [string, 's'][] = [];
880+
for (let i = 0; i < 50; i++) {
881+
rows.push([`/stress/${i}`, 's']);
882+
rows.push([`/stress/${i}/*`, 's']);
883+
}
884+
const out = coalesceRoutes(rows);
885+
assert.equal(out.length, 1);
886+
assert.deepEqual(out[0], ['/stress/*', 's']);
887+
});
888+
889+
void it('dedupes the duplicate wildcard from coalescing bare + subtree forms', () => {
890+
// Nuxt emits `/stress/N` AND `/stress/N/*`; both forms coalesce to
891+
// `/stress/*`, so the result must contain it exactly ONCE.
892+
const rows: [string, 's'][] = [
893+
['/stress/0', 's'],
894+
['/stress/0/*', 's'],
895+
['/stress/1', 's'],
896+
['/stress/1/*', 's'],
897+
];
898+
const out = coalesceRoutes(rows);
899+
assert.deepEqual(out, [['/stress/*', 's']]);
900+
});
901+
902+
void it('does NOT coalesce when sibling kinds differ (mixed static/compute)', () => {
903+
const rows: [string, 's' | 'c'][] = [
904+
['/mix/a', 's'],
905+
['/mix/b', 'c'],
906+
['/mix/c', 's'],
907+
];
908+
const out = coalesceRoutes(rows);
909+
// Mixed kind under /mix → left as individual rows (no lossy wildcard).
910+
assert.equal(out.length, 3);
911+
assert.ok(!out.some(([p]) => p === '/mix/*'));
912+
});
913+
914+
void it('retains a deeper differently-kinded route alongside a coalesced group', () => {
915+
// /blog/* (static pages) coalesces, but /blog/x/admin (compute) is under a
916+
// DIFFERENT parent (/blog/x) so it is never folded in, and its extra
917+
// literal segment sorts it ahead of /blog/* in buildKvsEntries.
918+
const rows: [string, 's' | 'c'][] = [
919+
['/blog/p1', 's'],
920+
['/blog/p2', 's'],
921+
['/blog/p3', 's'],
922+
['/blog/x/admin', 'c'],
923+
];
924+
const out = coalesceRoutes(rows);
925+
assert.ok(out.some(([p, k]) => p === '/blog/*' && k === 's'));
926+
assert.ok(out.some(([p, k]) => p === '/blog/x/admin' && k === 'c'));
927+
});
928+
929+
void it('leaves a single route untouched (no spurious wildcard)', () => {
930+
const rows: [string, 's'][] = [['/about', 's']];
931+
const out = coalesceRoutes(rows);
932+
assert.deepEqual(out, [['/about', 's']]);
933+
});
934+
935+
void it('never coalesces top-level routes into a site-wide /*', () => {
936+
// Root-level siblings (parent === '') must NOT become `/*` — that would
937+
// swallow every path. They stay as individual rows.
938+
const rows: [string, 's'][] = [
939+
['/about', 's'],
940+
['/contact', 's'],
941+
['/pricing', 's'],
942+
];
943+
const out = coalesceRoutes(rows);
944+
assert.equal(out.length, 3);
945+
assert.ok(!out.some(([p]) => p === '/*'));
946+
});
947+
948+
void it('bounds the route-chunk count for a large SSG deploy (regression)', () => {
949+
// 200 prerendered pages under /stress used to need ~7 r-chunks → 7
950+
// JSON.parse per request → instruction-limit 503 on the root path. After
951+
// coalescing they are one row → one chunk.
952+
const routes = [
953+
...Array.from({ length: 200 }, (_, i) => ({
954+
pattern: `/stress/${i}/*`,
955+
target: 'static',
956+
})),
957+
{ pattern: '/*', target: 'compute' },
958+
];
959+
const entries = buildKvsEntries({
960+
manifest: baseManifest({
961+
compute: { default: { type: 'handler', bundle: '/tmp', handler: 'h', placement: 'regional' } },
962+
staticAssets: { directory: '/tmp', spaFallback: false },
963+
routes,
964+
}),
965+
buildId: 'b1',
966+
hasServer: true,
967+
hasImage: false,
968+
});
969+
const meta = JSON.parse(entries.meta) as { rc: number };
970+
assert.equal(meta.rc, 1, 'coalesced SSG fan-out must fit in a single route chunk');
971+
});
972+
});

packages/hosting/src/constructs/kvs_router.ts

Lines changed: 83 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,79 @@ const normalizePattern = (pattern: string, basePath?: string): string => {
118118
return prependBasePath(basePath, p);
119119
};
120120

121+
/**
122+
* The viewer-request CloudFront Function scans the route table SEQUENTIALLY per
123+
* request — for an unmatched/catch-all path (the worst case: the site root) it
124+
* reads + `JSON.parse`s every `r{n}` chunk before falling through to the
125+
* default origin. CloudFront Functions cap per-invocation compute, so a table
126+
* with many rows (→ many chunks → many parses) trips `RangeError: Instruction
127+
* limit exceeded` and the distribution 503s on EVERY route (the function runs
128+
* before any origin). SSG sites are the trigger: a framework emits one static
129+
* route per prerendered page (`/blog/post-1`, `/blog/post-2`, … hundreds), and
130+
* Nuxt additionally emits a `/<page>/*` subtree route per page — so 100 pages
131+
* became 200 rows / 7 chunks and tipped the limit.
132+
*
133+
* Coalesce sibling routes that share a parent directory AND a single kind into
134+
* one `parent/*` wildcard, collapsing those hundreds of rows to one. The scan
135+
* mirrors CloudFront's first-match-on-specificity ordering, so this preserves
136+
* matching for every EXISTING path: a request that hit `/blog/post-5` (exact)
137+
* now hits `/blog/*` with the same kind; a deeper, differently-kinded route
138+
* (e.g. `/blog/post-5/admin` = compute) keeps its own row and still sorts
139+
* BEFORE the broader wildcard (more literal segments), so it matches first.
140+
*
141+
* Semantic note (intentional, documented): for a compute-backed deploy where
142+
* the unmatched default is the SSR origin, a request to a NON-existent child of
143+
* a coalesced STATIC group (e.g. `/blog/never-generated`) now routes to S3 (→
144+
* 404/403 from the bucket) instead of the SSR Lambda's framework 404 page. For
145+
* prerendered content a miss is a 404 either way; only the 404 styling differs.
146+
* Coalescing a COMPUTE group, or any group in a static-only deploy, is a pure
147+
* no-op (the wildcard kind equals the default), so this only affects static
148+
* routes in a compute deploy — exactly the SSG fan-out we need to bound.
149+
*/
150+
export const coalesceRoutes = (
151+
rows: [string, RouteKind][],
152+
): [string, RouteKind][] => {
153+
// Group by parent directory: strip a trailing '/*', then take everything up
154+
// to the last '/'. Both `/blog/p` and `/blog/p/*` → parent `/blog`.
155+
const groups = new Map<string, [string, RouteKind][]>();
156+
const order: string[] = [];
157+
for (const r of rows) {
158+
let p = r[0];
159+
if (p.endsWith('/*')) p = p.slice(0, -2);
160+
const slash = p.lastIndexOf('/');
161+
const parent = slash > 0 ? p.substring(0, slash) : '';
162+
if (!groups.has(parent)) {
163+
groups.set(parent, []);
164+
order.push(parent);
165+
}
166+
groups.get(parent)!.push(r);
167+
}
168+
const out: [string, RouteKind][] = [];
169+
for (const parent of order) {
170+
const members = groups.get(parent)!;
171+
const uniformKind = members.every((m) => m[1] === members[0][1]);
172+
// Coalesce only a real fan-out (≥2) under a non-root parent of one kind.
173+
// A non-empty parent guarantees the wildcard is scoped to a subtree and
174+
// never becomes a bare `/*` that would swallow the whole site.
175+
if (members.length >= 2 && uniformKind && parent.length > 0) {
176+
out.push([`${parent}/*`, members[0][1]]);
177+
} else {
178+
out.push(...members);
179+
}
180+
}
181+
// Dedupe identical [pattern, kind] rows. Frameworks that emit BOTH a bare
182+
// `/<page>` and a `/<page>/*` subtree per page (Nuxt) coalesce each form to
183+
// the SAME `<parent>/*` wildcard, producing duplicate rows; collapse them so
184+
// the table stays minimal.
185+
const seen = new Set<string>();
186+
return out.filter(([p, k]) => {
187+
const key = `${p}${k}`;
188+
if (seen.has(key)) return false;
189+
seen.add(key);
190+
return true;
191+
});
192+
};
193+
121194
/**
122195
* Build the KVS key/value map for a deploy. Keys:
123196
* - `meta` : metadata blob (buildId, basePath, spaFallback, image prefix,
@@ -159,9 +232,16 @@ export const buildKvsEntries = (input: BuildKvsInput): Record<string, string> =>
159232
const kind: RouteKind = isImage ? 'i' : isStatic ? 's' : 'c';
160233
rows.push([cf, kind]);
161234
}
235+
// Coalesce SSG fan-out (many sibling pages under one parent, one kind) into a
236+
// single `parent/*` wildcard so the per-request edge scan stays bounded and
237+
// never trips the CloudFront Function instruction limit. See coalesceRoutes.
238+
const coalesced = coalesceRoutes(rows);
239+
162240
// Sort by descending specificity (literal segments, then length) so the
163-
// function's first-match scan mirrors CloudFront's old behavior ordering.
164-
rows.sort((a, b) => specificity(b[0]) - specificity(a[0]));
241+
// function's first-match scan mirrors CloudFront's old behavior ordering. A
242+
// coalesced `/blog/*` (1 literal seg) sorts AFTER any retained deeper route
243+
// (e.g. `/blog/x/admin`, 3 segs), preserving first-match correctness.
244+
coalesced.sort((a, b) => specificity(b[0]) - specificity(a[0]));
165245

166246
// ---- redirects (basePath-prefixed, unbounded — no 100 cap) ----
167247
const redirects = manifest.redirects ?? [];
@@ -178,7 +258,7 @@ export const buildKvsEntries = (input: BuildKvsInput): Record<string, string> =>
178258
manifest.headers ?? []
179259
).map((h) => [normalizePattern(h.source, basePath), h.headers]);
180260

181-
const routeChunks = chunkRows(rows);
261+
const routeChunks = chunkRows(coalesced);
182262
const redirectChunks = chunkRows(redirectRows);
183263
const headerChunks = chunkRows(headerRows);
184264

0 commit comments

Comments
 (0)