Skip to content

Commit b9782f7

Browse files
committed
fix(hosting): serve Nuxt bare prerendered routes from S3 (frozen, no SSR)
The nitro adapter emitted only the subtree route (/about/*) for each prerendered page, leaving the bare canonical URL (/about) to fall through to the SSR Lambda — which re-rendered a prerender:true page on every request, breaking the 'frozen, served from S3, no Lambda' contract. The router's directory-index rewrite now appends /index.html to any extensionless path, so a bare path resolves to builds/<id>/<route>/index.html (exactly how Nuxt prerenders /about -> about/index.html). Emit BOTH the bare route and its subtree as static for both the filesystem walk and the routeRules prerender loop. Add a kvs_router regression test asserting /myapp/about routes to S3 and rewrites to the frozen index.html.
1 parent d129af0 commit b9782f7

2 files changed

Lines changed: 57 additions & 17 deletions

File tree

packages/hosting/src/adapters/nitro.ts

Lines changed: 25 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1434,36 +1434,44 @@ const buildRoutes = (
14341434
addRoute({ pattern, target: 'static' });
14351435
}
14361436

1437-
// Walk for prerendered HTML pages and emit a *subtree* route for each.
1437+
// Walk for prerendered HTML pages and emit BOTH the bare route and its
1438+
// subtree as static → S3:
1439+
// - `/<route>` → so the canonical bare URL serves the FROZEN prerendered
1440+
// HTML from S3 (the build-id rewrite's directory-index branch appends
1441+
// `/index.html` to any extensionless path → `builds/{id}/<route>/index.html`,
1442+
// which is exactly how Nuxt prerenders `/about` → `about/index.html`).
1443+
// - `/<route>/*` → so sibling assets the framework prefetches
1444+
// (`_payload.json`, etc.) also resolve from S3.
14381445
//
1439-
// We deliberately do NOT emit a bare `/<route>` static route — the
1440-
// CloudFront build-ID rewrite Function only appends `index.html` when
1441-
// the URI ends with `/`, so `/about` would resolve to the S3 key
1442-
// `builds/{id}/about` (no such object → 403). Instead we route
1443-
// `/<route>/*` to S3 (so `_payload.json` and other sibling assets the
1444-
// framework prefetches resolve), and let the bare `/<route>` flow
1445-
// through the catch-all to the SSR Lambda, which re-renders the page
1446-
// from the bundled component on demand.
1446+
// (Historically only `/<route>/*` was emitted and bare `/<route>` fell
1447+
// through to the SSR Lambda — which re-rendered a `prerender: true` page on
1448+
// every request, breaking the "frozen, served from S3, no Lambda" contract.
1449+
// The router's directory-index rewrite now handles bare extensionless paths,
1450+
// so the bare route resolves correctly from S3.)
14471451
for (const htmlFile of walkHtmlFiles(publicDir)) {
14481452
const rel = path.relative(publicDir, htmlFile).replace(/\\/g, '/');
14491453
const urlPath = htmlFileToUrlPath(rel);
14501454
if (urlPath === '/') continue;
1455+
addRoute({ pattern: urlPath, target: 'static' });
14511456
addRoute({ pattern: `${urlPath}/*`, target: 'static' });
14521457
}
14531458
}
14541459

14551460
// Honour route rules with `prerender: true` even if the build hasn't
1456-
// emitted a file at that path yet. We emit the subtree pattern only
1457-
// (e.g. `/about/*` not bare `/about`) for the same reason the
1458-
// filesystem walk above does — the build-ID rewriter can't resolve a
1459-
// bare prerendered path to its index.html.
1461+
// emitted a file at that path yet. Emit BOTH the bare route and its subtree
1462+
// as static (the router's directory-index rewrite resolves the bare
1463+
// extensionless path to its `index.html` on S3 — see the filesystem walk
1464+
// above), so a `prerender: true` page is served frozen from S3 rather than
1465+
// re-rendered by the SSR Lambda.
14601466
for (const [routePattern, rule] of Object.entries(routeRules)) {
14611467
if (rule.prerender) {
14621468
const normalized = normalizeRulePattern(routePattern);
1463-
const subtree = normalized.endsWith('/*')
1464-
? normalized
1465-
: `${normalized}/*`;
1466-
addRoute({ pattern: subtree, target: 'static' });
1469+
if (normalized.endsWith('/*')) {
1470+
addRoute({ pattern: normalized, target: 'static' });
1471+
} else {
1472+
addRoute({ pattern: normalized, target: 'static' });
1473+
addRoute({ pattern: `${normalized}/*`, target: 'static' });
1474+
}
14671475
}
14681476
}
14691477

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

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -585,6 +585,38 @@ void describe('request fn — G8 directory-index (spa=0)', () => {
585585
});
586586
});
587587

588+
void describe('request fn — bare prerendered route under basePath → S3 (Issue 1)', () => {
589+
// Regression: a Nuxt `prerender: true` page (e.g. /about) under basePath
590+
// /myapp must serve the FROZEN prerendered HTML from S3, not be re-rendered
591+
// by the SSR Lambda. The adapter now emits a bare `/about` static route (+
592+
// `/about/*`); the router strips basePath, classifies it static, and the
593+
// directory-index branch resolves the extensionless bare path to
594+
// /builds/<id>/about/index.html (how Nuxt prerenders it).
595+
const entries = buildKvsEntries({
596+
manifest: baseManifest({
597+
basePath: '/myapp',
598+
staticAssets: { directory: '/tmp', spaFallback: false },
599+
routes: [
600+
{ pattern: '/about', target: 'static' },
601+
{ pattern: '/about/*', target: 'static' },
602+
{ pattern: '/*', target: 'compute' },
603+
],
604+
}),
605+
buildId: 'b1',
606+
hasServer: true,
607+
hasImage: false,
608+
});
609+
void it('routes bare /myapp/about to S3 + rewrites to the frozen index.html', async () => {
610+
const { output, selectedOrigin } = await runRequestFn(
611+
reqCode,
612+
entries,
613+
req('/myapp/about'),
614+
);
615+
assert.equal(selectedOrigin, ORIGIN_ID.s3);
616+
assert.equal(output.uri, '/builds/b1/about/index.html');
617+
});
618+
});
619+
588620
void describe('request fn — G10 fail-open when meta is missing', () => {
589621
void it('returns the request unchanged (no origin selected) if KVS has no meta', async () => {
590622
const { output, selectedOrigin } = await runRequestFn(reqCode, {}, req('/x'));

0 commit comments

Comments
 (0)