@wular/pnext 0.0.2 → 0.0.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/README.md +76 -20
  2. package/package.json +3 -2
  3. package/reference/data/bench.json +513 -0
  4. package/reference/performance.md +75 -48
  5. package/src/api/router/runtime.ts +60 -21
  6. package/src/cache/context.ts +4 -1
  7. package/src/cli/build.ts +37 -5
  8. package/src/cli/dev.ts +6 -0
  9. package/src/cli/index.ts +17 -3
  10. package/src/cli/request-pipeline.ts +1340 -0
  11. package/src/cli/server-entry.ts +180 -0
  12. package/src/cli/start.ts +39 -1311
  13. package/src/client/build.ts +59 -20
  14. package/src/client/chunk-fold.ts +40 -0
  15. package/src/client/compat-surface.ts +175 -0
  16. package/src/client/entry.ts +67 -52
  17. package/src/compat/actions/action-client.ts +8 -1
  18. package/src/compat/actions/action-dispatch.ts +11 -1
  19. package/src/compat/actions/discovery.ts +23 -6
  20. package/src/compat/bundler/optimize-package-imports.ts +5 -1
  21. package/src/compat/bundler/worker.ts +2 -1
  22. package/src/compat/client/errors/bare-boundary.ts +32 -0
  23. package/src/compat/client/errors/error-boundary.ts +1 -15
  24. package/src/compat/client/errors/primitive-throw.ts +16 -0
  25. package/src/compat/client/link-status.ts +1 -1
  26. package/src/compat/css/lightningcss.ts +2 -1
  27. package/src/compat/css/modules.ts +4 -3
  28. package/src/compat/lifecycle/instrumentation-client.ts +1 -1
  29. package/src/compat/lifecycle/instrumentation.ts +5 -2
  30. package/src/compat/next/config-loader.ts +33 -5
  31. package/src/compat/next/dynamic.tsx +9 -5
  32. package/src/compat/next/link-validation-transform.ts +5 -1
  33. package/src/compat/next/link.tsx +51 -58
  34. package/src/compat/pages/client-plugin.ts +2 -1
  35. package/src/compat/react/action-state.ts +159 -0
  36. package/src/compat/react/client-lite.ts +74 -0
  37. package/src/compat/react/hooks-extra.ts +92 -0
  38. package/src/compat/react/parity.ts +128 -0
  39. package/src/compat/react/preact.ts +33 -420
  40. package/src/compat/react/server-inserted-html.ts +14 -7
  41. package/src/compat/react/use.ts +72 -0
  42. package/src/compat/register/actions.ts +27 -7
  43. package/src/compat/register/segment.ts +16 -6
  44. package/src/config.ts +15 -1
  45. package/src/css/build.ts +13 -2
  46. package/src/dev/imports.ts +34 -5
  47. package/src/dev/module-cache.ts +19 -0
  48. package/src/dev/module-transform.ts +7 -1
  49. package/src/dev/server.ts +91 -22
  50. package/src/dynamic/source.ts +36 -27
  51. package/src/ppr.ts +5 -4
  52. package/src/proxy.ts +5 -1
  53. package/src/render/island-context.ts +21 -3
  54. package/src/render/renderer.ts +102 -26
  55. package/src/resolve/engine.ts +12 -2
  56. package/src/resolve/scan-facts.ts +239 -1
  57. package/src/routing/href.ts +4 -5
  58. package/src/routing/routes.ts +26 -41
  59. package/src/runtime/server.ts +8 -4
  60. package/src/runtime/vendor.ts +1 -1
  61. package/src/typegen.ts +3 -3
  62. package/src/utils/esbuild.ts +58 -0
  63. package/src/utils/fs.ts +14 -2
  64. package/src/utils/native-require.ts +28 -0
@@ -0,0 +1,1340 @@
1
+ /**
2
+ * The production request pipeline. Split out of `start.ts` so the prebundled
3
+ * server entry parses only what listening needs: this module (renderer,
4
+ * routing, runtime — ~700 KB of the bundle) is dynamically imported after the
5
+ * port is bound and the ready banner is printed.
6
+ */
7
+ import { existsSync } from 'node:fs';
8
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
9
+ import { stat } from 'node:fs/promises';
10
+ import path from 'node:path';
11
+ import { constants as zlibConstants, createGzip, gzipSync } from 'node:zlib';
12
+ import { Readable } from 'node:stream';
13
+ import { loadConfig, pathToFileHref } from '../config';
14
+ import type { ResolvedConfig } from '../config';
15
+ import { bootstrapCompat } from '../compat-bootstrap';
16
+ import { registerServerRuntime, serverBundleTargetForRuntime } from '../runtime/server';
17
+ import { applyProxyResponse, createProxyRunner } from '../proxy';
18
+ import {
19
+ isPprShellUpgradeEligible,
20
+ renderGlobalNotFoundResponse,
21
+ renderPageResponse,
22
+ renderPageWithStatus,
23
+ } from '../render';
24
+ import {
25
+ handleRouteModule,
26
+ routeParamsFromPath,
27
+ type RouteHandlerModule,
28
+ } from '../routing/handler';
29
+ import {
30
+ canonicalTrailingSlashPath,
31
+ malformedUrlResponse,
32
+ trailingSlashRedirect,
33
+ } from '../routing/href';
34
+ import { withForwardedHeaders } from '../routing/forwarded';
35
+ import { normalizePathname, parseNavState, selectRouteForRequest } from '../routing/routes';
36
+ import { runWithCacheScope } from '../cache/context';
37
+ import { metadataRouteHandlerModule } from '../routing/metadata';
38
+ import {
39
+ finalizeResponse,
40
+ getAssetExtensions,
41
+ getRenderExtensions,
42
+ getProxyExtensions,
43
+ getRequestExtensions,
44
+ reportRequestError,
45
+ runInitHooks,
46
+ setRequestExtensions,
47
+ withRouteRuntime,
48
+ } from '../extensions';
49
+ import {
50
+ flushWorkUnit,
51
+ flushWorkUnitOnClose,
52
+ getWorkUnit,
53
+ runWithWorkUnit,
54
+ setPhase,
55
+ setWorkUnitRoute,
56
+ } from '../request/context';
57
+ import { setRequestRuntime } from '../routing/request-runtime';
58
+ import { getRenderSpanExtensions } from '../render/hooks';
59
+ import { contentType } from '../utils/content-type';
60
+ import { stopEsbuildService } from '../utils/esbuild';
61
+ import { markErrorLogged } from '../utils/error-log';
62
+ import type {
63
+ BuildManifest,
64
+ RouteManifestEntry,
65
+ RouteParamValue,
66
+ StaticFileMetadata,
67
+ } from '../types';
68
+
69
+ /** Compat module-mode route hrefs are the only prod use of the dev import layer; keep it off the start graph. */
70
+ async function moduleHrefForRoute(
71
+ config: ResolvedConfig,
72
+ route: RouteManifestEntry,
73
+ ): Promise<string> {
74
+ const { devServerModuleHref } = await import('../dev/imports');
75
+ return devServerModuleHref(config, route.file, 'build', {
76
+ conditionTarget: serverBundleTargetForRuntime(route.segmentConfig?.runtime),
77
+ });
78
+ }
79
+
80
+ /**
81
+ * The production request handler `pnext start` serves with. Exposed on its own
82
+ * so deployment adapters (e.g. Vercel) can serve the exact same pipeline
83
+ * without the Bun.serve wrapper.
84
+ */
85
+ export async function createRequestHandler(
86
+ options: { root?: string; config?: ResolvedConfig; manifest?: BuildManifest } = {},
87
+ ) {
88
+ // `start` already resolved (and bootstrapped) the config for its output-mode
89
+ // guard; reusing it skips a second loadConfig — env load, pnext.config +
90
+ // next.config imports, app-path resolution — before the ready banner.
91
+ const config = options.config ?? (await loadConfig(options.root));
92
+ // Compat plugin loader: the single gated seam that populates the core
93
+ // extension registries when compat is enabled (no-op for pure-core apps).
94
+ // A prod server answers the very next request, so it takes both tiers here.
95
+ await bootstrapCompat(config);
96
+ const manifestPath = path.join(config.outPath, 'manifest.json');
97
+ // `start` reads the manifest before binding the port (a missing build must
98
+ // still fail there, not on the first request) and hands it over.
99
+ const manifest =
100
+ options.manifest ?? (JSON.parse(await readFile(manifestPath, 'utf8')) as BuildManifest);
101
+ const persistManifest = () => writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
102
+ // Compat action-registry arming + serverActions.bodySizeLimit resolution +
103
+ // fetch-cache install now run through the extension registry: the action
104
+ // dispatch interceptor rebuilds the registry from manifest.actions on the
105
+ // first action request, and runInitHooks installs the Next fetch-cache patch
106
+ // so runtime renders observe force-cache / revalidate TTLs / tags exactly
107
+ // like build prerenders. No-op for pure-core apps.
108
+ runInitHooks(config);
109
+ // Publish the live routing state the compat request interceptors (action
110
+ // dispatch, rewrites) read; prod loads the route table once.
111
+ setRequestRuntime({ config, routes: manifest.routes, dev: false });
112
+ const proxyRunner = createProxyRunner(config, {
113
+ compiledModuleHref: manifest.proxyModule
114
+ ? pathToFileHref(path.resolve(config.outPath, manifest.proxyModule))
115
+ : undefined,
116
+ });
117
+ const routesById = new Map(manifest.routes.map(route => [route.id, route]));
118
+ const nextCompat = Boolean(config.compat?.next);
119
+ const regenerating = new Map<string, Promise<void>>();
120
+
121
+ /**
122
+ * ISR background regeneration (stale-while-revalidate): re-render the route
123
+ * that produced a prebuilt file whose TTL expired, write the fresh bytes
124
+ * over it, and refresh its manifest metadata (tags/TTL may change between
125
+ * renders). Deduped per file; failures keep serving the stale copy.
126
+ */
127
+ async function regenerateStaticFile(
128
+ pathname: string,
129
+ file: string,
130
+ relative: string,
131
+ metadata: StaticFileMetadata,
132
+ reason: 'stale' | 'on-demand',
133
+ ) {
134
+ if (!metadata.routeId) return;
135
+ const route = routesById.get(metadata.routeId);
136
+ if (!route) return;
137
+ await runWithWorkUnit('render', async () => {
138
+ const url = new URL(`http://pnext.local${pathname}`);
139
+ const params = routeParamsFromPath(route, pathname);
140
+ const fetchCache = route.segmentConfig?.fetchCache;
141
+ try {
142
+ if (route.kind === 'handler') {
143
+ setWorkUnitRoute('route-handler', 'isr', { revalidateReason: reason });
144
+ setPhase('handler');
145
+ registerServerRuntime(config, route.sourceFiles);
146
+ const href = compatModuleMode(config)
147
+ ? await moduleHrefForRoute(config, route)
148
+ : pathToFileHref(route.file);
149
+ const module = (await import(href)) as Parameters<typeof handleRouteModule>[0];
150
+ const rendered = await getRenderExtensions().collectRenderMeta(
151
+ () =>
152
+ withRouteRuntime(route.segmentConfig?.runtime, () =>
153
+ runWithCacheScope(() =>
154
+ handleRouteModule(module, new Request(url), params, { routeFile: route.file }),
155
+ ),
156
+ ),
157
+ { fetchCache, blockingStaleFetches: true, route: pathname, handler: true },
158
+ );
159
+ if (rendered.value.status >= 500) return;
160
+ await writeFile(file, new Uint8Array(await rendered.value.arrayBuffer()));
161
+ manifest.staticFiles![relative] = {
162
+ ...metadata,
163
+ status: rendered.value.status,
164
+ headers: [...rendered.value.headers.entries()],
165
+ ...(rendered.tags.length > 0 ? { tags: rendered.tags } : {}),
166
+ };
167
+ } else {
168
+ setWorkUnitRoute('html', 'isr', { revalidateReason: reason });
169
+ setPhase('render');
170
+ const rendered = await getRenderExtensions().collectRenderMeta(
171
+ () =>
172
+ withRouteRuntime(route.segmentConfig?.runtime, () =>
173
+ renderPageWithStatus({
174
+ config,
175
+ route,
176
+ params,
177
+ url,
178
+ ...(route.usesRequest ? { request: new Request(url) } : {}),
179
+ staticMetadataFiles: manifest.staticMetadataFiles,
180
+ staticModuleMetadata: manifest.staticModuleMetadata,
181
+ staticRouteMetadata: manifest.staticRouteMetadata,
182
+ }),
183
+ ),
184
+ { fetchCache, blockingStaleFetches: true, route: pathname },
185
+ );
186
+ // A failed regeneration (render threw → 5xx) must not overwrite the
187
+ // good prebuilt copy: Next discards the error and keeps serving the
188
+ // stale page (the error is still logged via the renderer's funnel).
189
+ if (rendered.value.status >= 500) return;
190
+ await writeFile(file, rendered.value.html);
191
+ manifest.staticFiles![relative] = {
192
+ ...metadata,
193
+ status: rendered.value.status,
194
+ ...(rendered.tags.length > 0 ? { tags: rendered.tags } : {}),
195
+ };
196
+ }
197
+ } catch (error) {
198
+ const unit = getWorkUnit();
199
+ await reportRequestError(
200
+ error,
201
+ { method: 'GET', url: url.href, headers: new Headers() },
202
+ {
203
+ phase: unit?.phase,
204
+ routeKind: unit?.routeKind,
205
+ ...(unit?.responseHints ?? {}),
206
+ },
207
+ );
208
+ throw error;
209
+ }
210
+ });
211
+ }
212
+
213
+ function runRegeneration(
214
+ pathname: string,
215
+ file: string,
216
+ relative: string,
217
+ metadata: StaticFileMetadata,
218
+ reason: 'stale' | 'on-demand',
219
+ ) {
220
+ const existing = regenerating.get(file);
221
+ if (existing) return existing;
222
+ const task = regenerateStaticFile(pathname, file, relative, metadata, reason)
223
+ .catch(error => {
224
+ console.warn(`pnext start: background revalidate failed for ${pathname}:`, error);
225
+ })
226
+ .finally(() => {
227
+ regenerating.delete(file);
228
+ });
229
+ regenerating.set(file, task);
230
+ return task;
231
+ }
232
+
233
+ function scheduleRegen(
234
+ pathname: string,
235
+ file: string,
236
+ relative: string,
237
+ metadata: StaticFileMetadata,
238
+ ) {
239
+ void runRegeneration(pathname, file, relative, metadata, 'stale');
240
+ }
241
+
242
+ setRequestExtensions({
243
+ onDemandRevalidatePath: async pathname => {
244
+ const built = await builtFileInfo(config.outPath, pathname, manifest.staticFiles);
245
+ if (!built?.metadata) return;
246
+ await runRegeneration(pathname, built.file, built.relative, built.metadata, 'on-demand');
247
+ },
248
+ });
249
+
250
+ async function renderNotFound(request: Request): Promise<Response> {
251
+ return compressResponse(
252
+ await renderGlobalNotFoundResponse({
253
+ config,
254
+ url: new URL(request.url),
255
+ request,
256
+ staticMetadataFiles: manifest.staticMetadataFiles,
257
+ staticModuleMetadata: manifest.staticModuleMetadata,
258
+ staticRouteMetadata: manifest.staticRouteMetadata,
259
+ }),
260
+ request,
261
+ );
262
+ }
263
+
264
+ // The boot config compile is the only build work a prod server does; drop the
265
+ // resident esbuild service child (~10+ MB RSS) — it respawns if ever needed.
266
+ stopEsbuildService();
267
+
268
+ return function handleRequest(request: Request): Promise<Response> {
269
+ // One work unit spans the whole request; its after-queue flushes once the
270
+ // response fully closes (stream end, redirect, notFound, error, abort).
271
+ return runWithWorkUnit('render', async () => {
272
+ const unit = getWorkUnit();
273
+ try {
274
+ const raw = await handle(request);
275
+ const finalized = await finalizeResponse(
276
+ raw,
277
+ { method: request.method, url: new URL(request.url), headers: request.headers },
278
+ {
279
+ routeKind: unit?.routeKind ?? 'html',
280
+ routeMode: unit?.routeMode,
281
+ hints: unit?.responseHints,
282
+ },
283
+ );
284
+ const response = maybeCloseNodeFetchConnection(finalized, request);
285
+ return flushWorkUnitOnClose(response, unit, request.signal);
286
+ } catch (error) {
287
+ // A dropped promise here would close the socket without a response
288
+ // (client sees "socket hang up"); surface a 500 instead. The error
289
+ // funnel (compat classifies + reports) fires from this single catch.
290
+ await reportRequestError(
291
+ error,
292
+ { method: request.method, url: request.url, headers: request.headers },
293
+ { phase: unit?.phase, routeKind: unit?.routeKind },
294
+ );
295
+ // Dedupe: the renderer may have already logged this SSR error inline
296
+ // before it propagated here. Log once per error object across log sites.
297
+ if (markErrorLogged(error)) {
298
+ console.error(`pnext start: request failed for ${request.url}:`, error);
299
+ }
300
+ flushWorkUnit(unit);
301
+ return new Response('Internal Server Error', { status: 500 });
302
+ }
303
+ });
304
+ };
305
+
306
+ async function handle(request: Request): Promise<Response> {
307
+ const badRequest = malformedUrlResponse(request);
308
+ if (badRequest) return badRequest;
309
+ request = withForwardedHeaders(request);
310
+ const requestedUrl = new URL(request.url);
311
+ // assetPrefix is independent from basePath. Strip a path-style asset
312
+ // prefix first so `/cdn/_next/static/*` remains servable even when the app
313
+ // itself lives under a different basePath.
314
+ const assetStripped = stripAssetPrefix(request, config.assetPrefix);
315
+ if (assetStripped) request = assetStripped;
316
+ // basePath: strip the configured prefix so all downstream routing, static
317
+ // lookup, and handler context see the app-relative path (Next serves the
318
+ // app under basePath; `usePathname`/`req.nextUrl.pathname` exclude it). A
319
+ // request that does not carry the prefix is outside the app → 404.
320
+ if (config.basePath && !assetStripped) {
321
+ const stripped = stripBasePath(request, config.basePath);
322
+ if (!stripped) {
323
+ // Outside the basePath: only a rule that opted out of it (next.config's
324
+ // `basePath: false` rewrites/redirects) may answer. If one rewrites the
325
+ // request back into the app, re-apply the strip; otherwise this is a 404
326
+ // exactly as before.
327
+ const answered = await runOutsideBasePathInterceptors(request, config);
328
+ if (answered instanceof Response) return answered;
329
+ const rewritten = answered && stripBasePath(answered.request, config.basePath);
330
+ if (!rewritten) return await renderNotFound(request);
331
+ request = rewritten;
332
+ } else {
333
+ request = stripped;
334
+ }
335
+ }
336
+ // `/_next/data/<buildId>/<page>.json` is the pages-router data protocol:
337
+ // Next serves it as a request for the PAGE path (trailing slash applied),
338
+ // normalized BEFORE middleware so the handler sees the page URL — or, with
339
+ // skipMiddlewareUrlNormalize, after it (middleware sees the raw data URL).
340
+ const skipProxyNormalize = getProxyExtensions().skipUrlNormalize();
341
+ if (nextCompat && !skipProxyNormalize) {
342
+ request = normalizeDataRequest(request, config) ?? request;
343
+ }
344
+ // A request authorized by the preview-mode id is Next's on-demand
345
+ // revalidation: middleware never runs for it and the route re-renders
346
+ // fresh below (the static fast path is skipped).
347
+ const bypassToken = getRequestExtensions().revalidateBypassToken();
348
+ const revalidateBypass = Boolean(
349
+ bypassToken && request.headers.get('x-prerender-revalidate') === bypassToken,
350
+ );
351
+ const canonicalUrl = new URL(request.url);
352
+ const canonicalHeaders = request.headers;
353
+ let proxyResponse: Response | undefined;
354
+ const proxyResult = revalidateBypass ? undefined : await proxyRunner(request);
355
+ if (proxyResult instanceof Response) return proxyResult;
356
+ if (proxyResult) {
357
+ request = proxyResult.request;
358
+ proxyResponse = proxyResult.response;
359
+ }
360
+ if (nextCompat && skipProxyNormalize) {
361
+ request = normalizeDataRequest(request, config) ?? request;
362
+ }
363
+
364
+ // The compat request interceptors run after the proxy, before route
365
+ // matching (registration order: action dispatch, then next.config
366
+ // rewrites). A Response short-circuits (wrapped with the proxy response); a
367
+ // { request } swaps the request (a rewrite) and continues. The render keeps
368
+ // the ORIGINAL requested pathname as canonical (usePathname never sees a
369
+ // rewrite's destination path), while matching / static lookup / staleness
370
+ // use the (possibly rewritten) url. Pure-core apps register no
371
+ // interceptors (a no-op).
372
+ for (const interceptor of getRequestExtensions().interceptors) {
373
+ const result = await interceptor(request, { config });
374
+ // Interceptor responses (segment/flight payloads, action results) sit in
375
+ // front of Next's compression middleware too — run them through the same
376
+ // gzip/Vary negotiation as rendered pages.
377
+ if (result instanceof Response)
378
+ return applyProxyResponse(compressResponse(result, request), proxyResponse);
379
+ if (result) request = result.request;
380
+ }
381
+ let url = new URL(request.url);
382
+ const rewritten = url.href !== canonicalUrl.href;
383
+ // Unlike pathname, Next DOES thread a rewrite's destination query into the page's `searchParams`
384
+ // prop - only the address bar and usePathname() stay on the as-requested path - for both
385
+ // middleware and next.config rewrites. So the canonical request keeps the original pathname but
386
+ // picks up whatever search ended up on the fully-resolved request.
387
+ const canonicalRequestUrl = new URL(canonicalUrl);
388
+ canonicalRequestUrl.search = url.search;
389
+ const canonicalRequest =
390
+ canonicalRequestUrl.href === request.url
391
+ ? request
392
+ : new Request(canonicalRequestUrl, { headers: canonicalHeaders });
393
+
394
+ const method = request.method.toUpperCase();
395
+ // Trailing-slash normalization applies to the browser-visible (as-requested)
396
+ // URL, never to an internal rewrite destination. A middleware/next.config
397
+ // rewrite to `/en/` must render internally, not 308 the visitor to `/en`.
398
+ const canonicalRedirect = trailingSlashRedirect(config, requestedUrl, method);
399
+ if (canonicalRedirect) return applyProxyResponse(canonicalRedirect, proxyResponse);
400
+ // Soft navigations carry the client's parallel-route state; when the
401
+ // target involves slots, interception, or a host render, the response is
402
+ // state-dependent and must render dynamically (never from prebuilt html).
403
+ const nav = parseNavState(request);
404
+ const selection = selectRouteForRequest(manifest.routes, url.pathname, nav);
405
+ const softDynamic = Boolean(
406
+ nav &&
407
+ selection?.route.kind === 'page' &&
408
+ (selection.route.slotDirs?.length ||
409
+ selection.route.synthetic ||
410
+ selection.route.interception ||
411
+ selection.childrenPath !== normalizePathname(url.pathname)),
412
+ );
413
+ // PPR shells carry an RDC sidecar and must resume through the renderer.
414
+ // Their persisted HTML is only the shell; serving it directly skips the
415
+ // dynamic continuation (and its RDC seed). Non-PPR static pages retain the
416
+ // normal static-file fast path.
417
+ const pprDocumentResume = selection?.route.kind === 'page' && selection.route.ppr;
418
+ // Draft mode: a __prerender_bypass cookie skips prebuilt page html so the
419
+ // request falls through to a fresh dynamic render below; non-page assets
420
+ // still serve statically and the stale-page write-back is disabled so a
421
+ // draft render never overwrites the prerendered copy.
422
+ const draftBypass = hasDraftBypassCookie(request.headers);
423
+ // revalidatePath()/revalidateTag() mark prebuilt output stale: skip the static copy (even on a
424
+ // matching etag - the bytes are stale) and fall through to a fresh blocking render. TTL expiry is
425
+ // served stale-while-revalidate instead: the stale copy goes out while a background regeneration
426
+ // rewrites it.
427
+ const built =
428
+ method === 'GET' || method === 'HEAD'
429
+ ? await builtFileInfo(
430
+ config.outPath,
431
+ url.pathname,
432
+ manifest.staticFiles,
433
+ nextCompat,
434
+ )
435
+ : null;
436
+ const hardStaticStale =
437
+ built !== null &&
438
+ // Content-hashed build assets are immutable and are never route outputs, so a
439
+ // revalidatePath/revalidateTag must not mark them stale. Without this,
440
+ // revalidatePath('/', 'layout') - whose pattern matches EVERY path - would flag the client entry
441
+ // script as stale, drop it from static serving, and leave the browser without the router
442
+ // runtime, silently downgrading every soft navigation to a hard one.
443
+ !isImmutableAssetFile(built.relative) &&
444
+ getRequestExtensions().staticStaleness(
445
+ url.pathname,
446
+ built.mtimeMs,
447
+ built.metadata?.tags ?? [],
448
+ );
449
+ const staleReason =
450
+ built
451
+ ? (getRequestExtensions().staticStalenessReason(
452
+ url.pathname,
453
+ built.mtimeMs,
454
+ built.metadata?.tags ?? [],
455
+ ) ?? 'on-demand')
456
+ : undefined;
457
+ const softStale = staleReason === 'soft';
458
+ const staleOnDemand = hardStaticStale && !softStale;
459
+ // A `use cache` hard-expiry (cacheLife expire) elapsed: the stale copy is no
460
+ // longer servable — fall through to a fresh blocking render (unlike ISR/SWR,
461
+ // which serves stale while regenerating). Only page HTML is regenerated
462
+ // this way; static handler bodies keep SWR semantics.
463
+ const hardExpired =
464
+ built?.metadata?.expireSeconds !== undefined &&
465
+ isHtmlPageFile(built.file) &&
466
+ Date.now() - built.mtimeMs >= built.metadata.expireSeconds * 1000;
467
+ // Route-shape bypasses (a PPR page resumes through the renderer; a soft navigation with
468
+ // parallel-slot state re-renders) only apply when the built file IS the page's HTML. A plain ASSET
469
+ // whose path merely matches a page route pattern must always serve statically - rendering the
470
+ // route for it hands the browser HTML for a module script.
471
+ const builtIsPageHtml = built !== null && isHtmlPageFile(built.file);
472
+ if (
473
+ built &&
474
+ !staleOnDemand &&
475
+ !(softDynamic && builtIsPageHtml) &&
476
+ !(pprDocumentResume && builtIsPageHtml) &&
477
+ !hardExpired &&
478
+ !revalidateBypass
479
+ ) {
480
+ const servedBuilt = built;
481
+ const metadata = built.metadata;
482
+ const route = metadata?.routeId ? routesById.get(metadata.routeId) : undefined;
483
+ if (
484
+ route?.segmentConfig?.runtime === 'edge' ||
485
+ route?.segmentConfig?.runtime === 'experimental-edge'
486
+ ) {
487
+ withRouteRuntime(route.segmentConfig.runtime, () => undefined);
488
+ }
489
+ // A stale static handler body keeps stale-while-revalidate semantics: the generic serving path
490
+ // below serves the stale bytes immediately with an x-nextjs-cache: STALE marker and schedules
491
+ // the regeneration in the background. Awaiting the regen here would race that background refresh
492
+ // - a fast regen would complete first and serve FRESH bytes where the client expects STALE.
493
+ const staticFile = await maybeBuiltFile(
494
+ config.outPath,
495
+ url.pathname,
496
+ manifest.staticFiles,
497
+ request.method,
498
+ request.headers,
499
+ nextCompat,
500
+ );
501
+ if (staticFile && !(draftBypass && isHtmlResponse(staticFile))) {
502
+ const staticMode = servedBuilt.metadata?.revalidateSeconds !== undefined ? 'isr' : 'static';
503
+ setWorkUnitRoute(
504
+ isHtmlResponse(staticFile) ? 'html' : 'static-asset',
505
+ staticMode,
506
+ servedBuilt.metadata?.revalidateSeconds !== undefined
507
+ ? { revalidateSeconds: servedBuilt.metadata.revalidateSeconds }
508
+ : undefined,
509
+ );
510
+ if (servedBuilt.metadata) {
511
+ const servedTtl = servedBuilt.metadata.revalidateSeconds;
512
+ const expired =
513
+ softStale ||
514
+ (servedTtl !== undefined &&
515
+ Date.now() - servedBuilt.mtimeMs >= servedTtl * 1000);
516
+ if (expired) {
517
+ scheduleRegen(
518
+ url.pathname,
519
+ servedBuilt.file,
520
+ servedBuilt.relative,
521
+ servedBuilt.metadata,
522
+ );
523
+ staticFile.headers.set('x-nextjs-cache', 'STALE');
524
+ } else {
525
+ staticFile.headers.set('x-nextjs-cache', 'HIT');
526
+ }
527
+ }
528
+ return applyProxyResponse(compressResponse(staticFile, request), proxyResponse);
529
+ }
530
+ }
531
+ if (url.pathname.startsWith('/_next/static/') && !built) {
532
+ setWorkUnitRoute('static-asset', 'static');
533
+ return applyProxyResponse(
534
+ new Response('Not Found', {
535
+ status: 404,
536
+ headers: { 'content-type': 'text/plain' },
537
+ }),
538
+ proxyResponse,
539
+ );
540
+ }
541
+ const stalePage =
542
+ built && (staleOnDemand || hardExpired) && isHtmlPageFile(built.file) ? built.file : null;
543
+
544
+ const matched = selection;
545
+ if (!matched) {
546
+ return applyProxyResponse(
547
+ compressResponse(
548
+ await renderGlobalNotFoundResponse({
549
+ config,
550
+ url,
551
+ request: canonicalRequest,
552
+ staticMetadataFiles: manifest.staticMetadataFiles,
553
+ staticModuleMetadata: manifest.staticModuleMetadata,
554
+ staticRouteMetadata: manifest.staticRouteMetadata,
555
+ }),
556
+ request,
557
+ ),
558
+ proxyResponse,
559
+ );
560
+ }
561
+ if (matched.route.kind === 'handler') {
562
+ setWorkUnitRoute(
563
+ 'route-handler',
564
+ staleOnDemand ? 'isr' : routeModeOf(matched.route),
565
+ responseHintsFor(matched.route, staleReason),
566
+ );
567
+ setPhase('handler');
568
+ // On-demand revalidated renders refetch their data caches (Next's
569
+ // isOnDemandRevalidate semantics) and persist the fresh bytes so
570
+ // subsequent requests serve the regenerated static copy again.
571
+ const renderedHandler = await getRenderExtensions().collectRenderMeta(
572
+ () => handleRoute(config, matched.route, canonicalRequest, matched.params),
573
+ {
574
+ fetchCache: matched.route.segmentConfig?.fetchCache,
575
+ refreshFetches: staleOnDemand,
576
+ route: url.pathname,
577
+ handler: true,
578
+ },
579
+ );
580
+ const handlerResponse = renderedHandler.value;
581
+ if (
582
+ built &&
583
+ staleOnDemand &&
584
+ !stalePage &&
585
+ method === 'GET' &&
586
+ handlerResponse.status === 200
587
+ ) {
588
+ await writeFile(built.file, new Uint8Array(await handlerResponse.clone().arrayBuffer()));
589
+ handlerResponse.headers.set('x-nextjs-cache', 'MISS');
590
+ }
591
+ if (
592
+ !built &&
593
+ matched.route.hasStaticParams &&
594
+ // force-dynamic / revalidate 0 opt the handler out of static output:
595
+ // every request must re-run it (generateSitemaps + force-dynamic must
596
+ // produce a fresh sitemap per request — dynamic-in-generate-params).
597
+ matched.route.segmentConfig?.dynamic !== 'force-dynamic' &&
598
+ matched.route.segmentConfig?.revalidate !== 0 &&
599
+ method === 'GET' &&
600
+ handlerResponse.status < 500
601
+ ) {
602
+ const file = lazyStaticHandlerPath(config.outPath, url.pathname);
603
+ if (file) {
604
+ await mkdir(path.dirname(file), { recursive: true });
605
+ await writeFile(file, new Uint8Array(await handlerResponse.clone().arrayBuffer()));
606
+ const relative = path
607
+ .relative(path.join(config.outPath, 'public'), file)
608
+ .split(path.sep)
609
+ .join('/');
610
+ const routeRevalidate = matched.route.segmentConfig?.revalidate;
611
+ const revalidateSeconds =
612
+ typeof routeRevalidate === 'number' && routeRevalidate > 0
613
+ ? renderedHandler.revalidateSeconds === undefined
614
+ ? routeRevalidate
615
+ : Math.min(routeRevalidate, renderedHandler.revalidateSeconds)
616
+ : renderedHandler.revalidateSeconds;
617
+ manifest.staticFiles ??= {};
618
+ manifest.staticFiles[relative] = {
619
+ status: handlerResponse.status,
620
+ headers: [...handlerResponse.headers.entries()],
621
+ routeId: matched.route.id,
622
+ ...(revalidateSeconds !== undefined ? { revalidateSeconds } : {}),
623
+ ...(renderedHandler.tags.length > 0 ? { tags: renderedHandler.tags } : {}),
624
+ };
625
+ await persistManifest();
626
+ handlerResponse.headers.set('x-nextjs-cache', 'MISS');
627
+ }
628
+ }
629
+ return applyProxyResponse(handlerResponse, proxyResponse);
630
+ }
631
+
632
+ // dynamic = 'error': request-data access is a hard error instead of a
633
+ // dynamic render (Next fails these at build; we surface a 500 at runtime).
634
+ if (matched.route.segmentConfig?.dynamic === 'error' && matched.route.usesRequest) {
635
+ return applyProxyResponse(
636
+ new Response(
637
+ `Page with dynamic = "error" encountered dynamic data method on ${url.pathname}`,
638
+ { status: 500 },
639
+ ),
640
+ proxyResponse,
641
+ );
642
+ }
643
+
644
+ // dynamicParams=false: dynamic segments governed by it must resolve to a
645
+ // prerendered param set; anything else is a 404. (Prebuilt paths were
646
+ // already served from the static output above.)
647
+ if (!dynamicParamsAllowed(matched.route, matched.params)) {
648
+ return applyProxyResponse(
649
+ compressResponse(
650
+ await renderGlobalNotFoundResponse({
651
+ config,
652
+ url,
653
+ request: canonicalRequest,
654
+ staticMetadataFiles: manifest.staticMetadataFiles,
655
+ staticModuleMetadata: manifest.staticModuleMetadata,
656
+ staticRouteMetadata: manifest.staticRouteMetadata,
657
+ }),
658
+ request,
659
+ ),
660
+ proxyResponse,
661
+ );
662
+ }
663
+
664
+ // force-static routes render with an empty request and no search params
665
+ // even when served dynamically (Next returns empty cookies/headers/params).
666
+ const forceStaticRoute = matched.route.segmentConfig?.dynamic === 'force-static';
667
+ const renderUrl = forceStaticRoute
668
+ ? new URL(canonicalUrl.pathname, canonicalUrl.origin)
669
+ : canonicalRequestUrl;
670
+ // Non-action POSTs to a page render the page like Next's MPA fallback (a
671
+ // plain <form method="POST"> submit, or a browser following a 307/308 from
672
+ // a route handler) instead of a 405.
673
+ const renderRequest = forceStaticRoute
674
+ ? new Request(renderUrl)
675
+ : method === 'POST'
676
+ ? new Request(canonicalRequest.url, { headers: canonicalRequest.headers })
677
+ : canonicalRequest;
678
+ const pageRevalidateReason = stalePage ? staleReason : hardExpired ? 'on-demand' : undefined;
679
+ setWorkUnitRoute(
680
+ 'html',
681
+ pageRevalidateReason ? 'isr' : routeModeOf(matched.route),
682
+ responseHintsFor(matched.route, pageRevalidateReason),
683
+ );
684
+ const renderMatchedPage = () =>
685
+ withRouteRuntime(matched.route.segmentConfig?.runtime, () =>
686
+ renderPageResponse({
687
+ config,
688
+ route: matched.route,
689
+ params: matched.params,
690
+ url: renderUrl,
691
+ request: renderRequest,
692
+ staticMetadataFiles: manifest.staticMetadataFiles,
693
+ staticModuleMetadata: manifest.staticModuleMetadata,
694
+ staticRouteMetadata: manifest.staticRouteMetadata,
695
+ ...(nav || rewritten
696
+ ? {
697
+ nav: {
698
+ ...(nav ? { soft: true, state: nav } : {}),
699
+ childrenPath: matched.childrenPath,
700
+ targetPath: matched.targetPath,
701
+ },
702
+ }
703
+ : {}),
704
+ }),
705
+ );
706
+ // Every render carries a cache-meta scope: the route's fetchCache config
707
+ // drives fetch cache-mode defaults; on-demand revalidated renders also
708
+ // refetch their data caches (Next's isOnDemandRevalidate semantics).
709
+ const renderedPage = await getRenderExtensions().collectRenderMeta(renderMatchedPage, {
710
+ fetchCache: matched.route.segmentConfig?.fetchCache,
711
+ refreshFetches: Boolean(stalePage),
712
+ route: url.pathname,
713
+ });
714
+ const renderRevalidateSeconds =
715
+ renderedPage.revalidateSeconds ??
716
+ (typeof matched.route.segmentConfig?.revalidate === 'number'
717
+ ? matched.route.segmentConfig.revalidate
718
+ : undefined);
719
+ // Only a POSITIVE revalidate window is ISR: `revalidate = 0` is Next's fully-dynamic opt-out
720
+ // (never cached, dynamic staleTime). Promoting a 0-second window to 'isr' handed the route the
721
+ // STATIC client staleTime via the segment finalizer, so a soft push to an already-visited dynamic
722
+ // page reused the first render.
723
+ //
724
+ // ...and only a route that actually rendered statically can be ISR: a fetch-level
725
+ // `next.revalidate` sets that fetch's DATA cache TTL and must never convert a request-API page
726
+ // into ISR. Promoting one handed the document and its _rsc payload a browser-cacheable s-maxage,
727
+ // so after a server action revalidated the path the browser replayed the pre-revalidation payload.
728
+ // A `segmentConfig.revalidate > 0` route is already classified 'isr' by routeModeOf above.
729
+ const renderWasDynamic =
730
+ matched.route.mode === 'dynamic' || matched.route.usesRequest || renderedPage.noStore === true;
731
+ if (renderRevalidateSeconds !== undefined && renderRevalidateSeconds > 0 && !renderWasDynamic) {
732
+ setWorkUnitRoute('html', 'isr', {
733
+ ...(getWorkUnit()?.responseHints ?? {}),
734
+ revalidateSeconds: renderRevalidateSeconds,
735
+ });
736
+ }
737
+ const pageResponse = renderedPage.value;
738
+
739
+ // A PPR resume replays the prebuilt shell without re-running the
740
+ // components whose react-dom preload()/font hints produced the prerender's
741
+ // `Link` header — re-emit the build-captured value (Next serves the
742
+ // prerender's stored headers the same way).
743
+ if (matched.route.linkHeader && !pageResponse.headers.has('link')) {
744
+ pageResponse.headers.set('link', matched.route.linkHeader);
745
+ }
746
+
747
+ // A prebuilt `use cache` page never re-runs its cache scopes, so the render
748
+ // response finalizer can't stamp its SWR cache-control / x-nextjs-stale-time
749
+ // (for PPR routes the cacheLife is stashed on the shell-resume work unit, not
750
+ // this one). Re-emit them from the route's build-captured cacheLife.
751
+ const cacheLifeHeaders = cacheLifeResponseHeaders(matched.route.cacheLife);
752
+ if (cacheLifeHeaders.length > 0 && matched.route.kind === 'page') {
753
+ for (const [key, value] of cacheLifeHeaders) {
754
+ if (key === 'cache-control' && pageResponse.headers.has('cache-control')) continue;
755
+ pageResponse.headers.set(key, value);
756
+ }
757
+ }
758
+
759
+ // Lazily generated static paths (force-static, or a static-capable route
760
+ // whose param set wasn't prerendered): persist the render so subsequent
761
+ // requests serve it as a HIT, Next-style.
762
+ const lazyStaticCapable =
763
+ matched.route.kind === 'page' &&
764
+ // A request that can promote a fallback shell into a more specific route
765
+ // shell must keep re-entering the renderer: caching its first (still
766
+ // un-upgraded) render on disk would serve every later request as a HIT
767
+ // and the upgraded shell would never reach the client.
768
+ !isPprShellUpgradeEligible(config, matched.route, url) &&
769
+ (forceStaticRoute ||
770
+ (!matched.route.usesRequest &&
771
+ // Next never statically caches `runtime = 'edge'` pages (they are
772
+ // excluded from build prerendering too — see build.ts); their data
773
+ // stability comes from the fetch cache alone.
774
+ matched.route.segmentConfig?.runtime !== 'edge' &&
775
+ (matched.route.hasStaticParams || matched.route.mode === 'static')));
776
+ if (
777
+ !stalePage &&
778
+ !built &&
779
+ !nav &&
780
+ !draftBypass &&
781
+ lazyStaticCapable &&
782
+ !renderedPage.noStore &&
783
+ method === 'GET' &&
784
+ url.search === '' &&
785
+ pageResponse.status === 200 &&
786
+ (pageResponse.headers.get('content-type') ?? '').includes('text/html')
787
+ ) {
788
+ try {
789
+ const file = lazyStaticHtmlPath(config.outPath, url.pathname);
790
+ if (!file) throw new Error('unsafe path');
791
+ await mkdir(path.dirname(file), { recursive: true });
792
+ await writeFile(file, await pageResponse.clone().text());
793
+ const relative = path
794
+ .relative(path.join(config.outPath, 'public'), file)
795
+ .split(path.sep)
796
+ .join('/');
797
+ manifest.staticFiles ??= {};
798
+ manifest.staticFiles[relative] = {
799
+ status: 200,
800
+ // Persist the cacheLife SWR headers (and the route's prerendered
801
+ // Link header) so later static HITs (served by maybeBuiltFile
802
+ // straight off disk) re-emit them without a render.
803
+ headers: [
804
+ ...(matched.route.linkHeader
805
+ ? ([['link', matched.route.linkHeader]] as [string, string][])
806
+ : []),
807
+ ...cacheLifeHeaders,
808
+ ],
809
+ routeId: matched.route.id,
810
+ kind: 'page',
811
+ ...(renderedPage.revalidateSeconds !== undefined
812
+ ? { revalidateSeconds: renderedPage.revalidateSeconds }
813
+ : {}),
814
+ ...(matched.route.cacheLife?.expireSeconds !== undefined
815
+ ? { expireSeconds: matched.route.cacheLife.expireSeconds }
816
+ : {}),
817
+ ...(matched.route.cacheLife?.staleSeconds !== undefined
818
+ ? { staleSeconds: matched.route.cacheLife.staleSeconds }
819
+ : {}),
820
+ ...(renderedPage.tags.length > 0 ? { tags: renderedPage.tags } : {}),
821
+ };
822
+ pageResponse.headers.set('x-nextjs-cache', 'MISS');
823
+ } catch {
824
+ // Lazy caching is best-effort; the response is still served.
825
+ }
826
+ }
827
+ // Write the fresh render back over the stale prebuilt html so subsequent requests serve the
828
+ // regenerated static copy again. A client soft-navigation render is request-scoped - persisting it
829
+ // would freeze a transient view over the prebuilt copy AND clear the on-demand staleness (the
830
+ // written mtime outruns the revalidation timestamp), so a following hard reload would serve the
831
+ // frozen soft-nav HTML instead of re-prerendering.
832
+ if (
833
+ stalePage &&
834
+ !nav &&
835
+ !softDynamic &&
836
+ !draftBypass &&
837
+ method === 'GET' &&
838
+ pageResponse.status === 200 &&
839
+ (pageResponse.headers.get('content-type') ?? '').includes('text/html')
840
+ ) {
841
+ await writeFile(stalePage, await pageResponse.clone().text());
842
+ pageResponse.headers.set('x-nextjs-cache', 'MISS');
843
+ }
844
+ // On-demand revalidation (valid x-prerender-revalidate): the fresh render
845
+ // replaces any prebuilt copy and is reported as REVALIDATED (Next's
846
+ // res.revalidate() / revalidatePath-over-HTTP semantics).
847
+ if (revalidateBypass) {
848
+ if (
849
+ built &&
850
+ method === 'GET' &&
851
+ pageResponse.status === 200 &&
852
+ (pageResponse.headers.get('content-type') ?? '').includes('text/html')
853
+ ) {
854
+ await writeFile(built.file, await pageResponse.clone().text());
855
+ }
856
+ pageResponse.headers.set('x-nextjs-cache', 'REVALIDATED');
857
+ }
858
+ return applyProxyResponse(compressResponse(pageResponse, request), proxyResponse);
859
+ }
860
+ }
861
+
862
+ // Rewrite a `/_next/data/<buildId>/<page>.json` request into a request for the
863
+ // page path itself (Next's data-route normalization): `/index` maps to `/`,
864
+ // the app's trailing-slash rule applies, and the `x-nextjs-data` marker header
865
+ // is stamped so downstream (middleware protocol, the compat pages-data
866
+ // interceptor) can tell it apart from a document request. Non-data URLs (and
867
+ // non-GET/HEAD methods) return null.
868
+ function normalizeDataRequest(
869
+ request: Request,
870
+ config: { trailingSlash?: boolean },
871
+ ): Request | null {
872
+ const method = request.method.toUpperCase();
873
+ if (method !== 'GET' && method !== 'HEAD') return null;
874
+ const url = new URL(request.url);
875
+ const match = /^\/_next\/data\/[^/]+(\/.+)\.json$/.exec(url.pathname);
876
+ if (!match) return null;
877
+ const page = match[1] === '/index' ? '/' : match[1]!;
878
+ url.pathname = canonicalTrailingSlashPath(page, Boolean(config.trailingSlash));
879
+ const headers = new Headers(request.headers);
880
+ headers.set('x-nextjs-data', '1');
881
+ return new Request(url, { method, headers });
882
+ }
883
+
884
+ // Give the basePath-independent interceptors (compat's `basePath: false`
885
+ // rewrites/redirects) their turn at a path core would otherwise 404. Returns the
886
+ // answering Response, a swapped request to continue with, or undefined when no
887
+ // rule claims it. A pure-core app registers none, so this is a no-op.
888
+ async function runOutsideBasePathInterceptors(
889
+ request: Request,
890
+ config: Awaited<ReturnType<typeof loadConfig>>,
891
+ ): Promise<Response | { request: Request } | undefined> {
892
+ for (const interceptor of getRequestExtensions().outsideBasePathInterceptors) {
893
+ const result = await interceptor(request, { config, outsideBasePath: true });
894
+ if (result) return result;
895
+ }
896
+ return undefined;
897
+ }
898
+
899
+ // Remove the configured basePath prefix from the request URL, returning a
900
+ // rewritten Request. Matches `/basePath` exactly and `/basePath/...`; returns
901
+ // null when the path is outside basePath (Next 404s those). The root of the
902
+ // app (`/basePath`) maps to `/`.
903
+ function stripBasePath(request: Request, basePath: string): Request | null {
904
+ const url = new URL(request.url);
905
+ const { pathname } = url;
906
+ if (pathname !== basePath && !pathname.startsWith(`${basePath}/`)) return null;
907
+ url.pathname = pathname.slice(basePath.length) || '/';
908
+ return new Request(url, request);
909
+ }
910
+
911
+ // Remove a configured PATH-style assetPrefix from an asset request URL so the
912
+ // static lookup resolves. Only `<prefix>/assets/*` and `<prefix>/_next/static/*`
913
+ // are stripped (asset URLs); other paths pass through untouched. Returns null
914
+ // when the prefix is absent or the path is not a prefixed asset path. For an
915
+ // absolute prefix, only its pathname is matched so local production starts can
916
+ // serve the same generated asset URLs.
917
+ function stripAssetPrefix(request: Request, assetPrefix: string | undefined): Request | null {
918
+ if (!assetPrefix) return null;
919
+ let prefixValue = assetPrefix;
920
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(assetPrefix)) {
921
+ prefixValue = new URL(assetPrefix).pathname;
922
+ }
923
+ const prefix = `/${prefixValue.replace(/^\/+|\/+$/g, '')}`;
924
+ if (prefix === '/') return null;
925
+ const url = new URL(request.url);
926
+ const { pathname } = url;
927
+ if (
928
+ !pathname.startsWith(`${prefix}/assets/`) &&
929
+ !pathname.startsWith(`${prefix}/_next/static/`)
930
+ ) {
931
+ return null;
932
+ }
933
+ url.pathname = pathname.slice(prefix.length);
934
+ return new Request(url, request);
935
+ }
936
+
937
+ function maybeCloseNodeFetchConnection(response: Response, request: Request) {
938
+ const userAgent = request.headers.get('user-agent') ?? '';
939
+ if (!userAgent.includes('node-fetch')) return response;
940
+ response.headers.set('connection', 'close');
941
+ return response;
942
+ }
943
+
944
+ // Compat mode gate (a pure read of config.compat, no compat import): under
945
+ // compat the compiled module is loaded so bare next/* / react aliases resolve
946
+ // to the compat layer. Mirrors compat/index.ts reactCompatEnabled — kept inline
947
+ // so start.ts carries no static edge into compat.
948
+ function compatModuleMode(config: Awaited<ReturnType<typeof loadConfig>>): boolean {
949
+ return Boolean(config.compat?.next || config.compat?.react || config.compat?.reactCompiler);
950
+ }
951
+
952
+ async function handleRoute(
953
+ config: Awaited<ReturnType<typeof loadConfig>>,
954
+ route: RouteManifestEntry,
955
+ request: Request,
956
+ params: Record<string, RouteParamValue>,
957
+ ) {
958
+ return withRouteRuntime(route.segmentConfig?.runtime, async () => {
959
+ registerServerRuntime(config, route.sourceFiles);
960
+ // Module resolution and handler invocation run through the render-span seam
961
+ // (compat/otel emits `resolve page components` + `executing api route`;
962
+ // pass-through for pure-core apps).
963
+ const spans = getRenderSpanExtensions();
964
+ const module = await spans.withFindPageComponentsSpan(route.route, async () => {
965
+ // Compat mode loads the compiled module (aliases baked in): a raw import
966
+ // would resolve bare next/* specifiers (next/headers etc.) against whatever
967
+ // next package is installed instead of the compat layer — Bun's runtime
968
+ // onResolve plugin cannot alias bare imports. Same pattern as pageRender.
969
+ const href = compatModuleMode(config)
970
+ ? await moduleHrefForRoute(config, route)
971
+ : new URL(`file://${route.file}`).href;
972
+ const imported = (await import(href)) as RouteHandlerModule &
973
+ Parameters<typeof metadataRouteHandlerModule>[0];
974
+ return (metadataRouteHandlerModule(imported, route) ?? imported) as RouteHandlerModule;
975
+ });
976
+ return spans.withRouteHandlerSpan(route.route, () =>
977
+ runWithCacheScope(() =>
978
+ handleRouteModule(module, request, params, { routeFile: route.file }),
979
+ ),
980
+ );
981
+ });
982
+ }
983
+
984
+ // Draft mode (draftMode().enable() from next/headers) marks the browser with
985
+ // a bypass cookie; requests carrying it must not see prerendered page html.
986
+ function hasDraftBypassCookie(headers: Headers) {
987
+ return /(?:^|;\s*)__prerender_bypass=/.test(headers.get('cookie') ?? '');
988
+ }
989
+
990
+ function isHtmlResponse(response: Response) {
991
+ return (response.headers.get('content-type') ?? '').includes('text/html');
992
+ }
993
+
994
+ // Core's coarse route caching disposition for the work unit / finalizers. ISR
995
+ // (a static route with a revalidate TTL) is reported as 'isr'; compat can refine
996
+ // the exact cache-control strings in its response finalizers.
997
+ function routeModeOf(route: RouteManifestEntry): 'static' | 'isr' | 'dynamic' {
998
+ if (route.segmentConfig?.dynamic === 'force-dynamic') return 'dynamic';
999
+ const revalidate = route.segmentConfig?.revalidate;
1000
+ if (typeof revalidate === 'number' && revalidate > 0) return 'isr';
1001
+ return route.mode === 'static' ? 'static' : 'dynamic';
1002
+ }
1003
+
1004
+ // Compat classification hints carried on the work unit for response finalizers:
1005
+ // the revalidate reason (on-demand vs stale) and the route's runtime (so compat
1006
+ // can stamp `x-edge-runtime` on edge routes). Undefined when neither applies.
1007
+ function responseHintsFor(
1008
+ route: RouteManifestEntry,
1009
+ revalidateReason: string | undefined,
1010
+ ): Record<string, unknown> | undefined {
1011
+ const runtime = route.segmentConfig?.runtime;
1012
+ const revalidate = route.segmentConfig?.revalidate;
1013
+ if (!revalidateReason && !runtime && typeof revalidate !== 'number') return undefined;
1014
+ return {
1015
+ ...(revalidateReason ? { revalidateReason } : {}),
1016
+ ...(runtime ? { runtime } : {}),
1017
+ ...(typeof revalidate === 'number' && revalidate > 0
1018
+ ? { revalidateSeconds: revalidate }
1019
+ : {}),
1020
+ };
1021
+ }
1022
+
1023
+ interface BuiltFileInfo {
1024
+ file: string;
1025
+ relative: string;
1026
+ mtimeMs: number;
1027
+ metadata?: StaticFileMetadata;
1028
+ }
1029
+
1030
+ // The prebuilt output file a GET for `pathname` would serve (page html or a
1031
+ // static route-handler body), plus its ISR metadata from the build manifest.
1032
+ async function builtFileInfo(
1033
+ outPath: string,
1034
+ pathname: string,
1035
+ staticFiles: Record<string, StaticFileMetadata> = {},
1036
+ nextStaticFallback = false,
1037
+ ): Promise<BuiltFileInfo | null> {
1038
+ const file = await firstFile(
1039
+ path.join(outPath, 'public'),
1040
+ builtFileCandidates(outPath, pathname, nextStaticFallback),
1041
+ );
1042
+ if (!file) return null;
1043
+ const publicPath = path.join(outPath, 'public');
1044
+ const fileStat = await stat(file);
1045
+ const relative = path.relative(publicPath, file).split(path.sep).join('/');
1046
+ return { file, relative, mtimeMs: fileStat.mtimeMs, metadata: staticFiles[relative] };
1047
+ }
1048
+
1049
+ // The prebuilt files a GET for `pathname` could resolve to, most-specific
1050
+ // first: the dir layout (`/a/index.html`), a raw static asset (`/a`), and the
1051
+ // flat export layout (`/a.html`, output:'export' + trailingSlash:false).
1052
+ function builtFileCandidates(outPath: string, pathname: string, nextStaticFallback = false): string[] {
1053
+ const publicPath = path.join(outPath, 'public');
1054
+ const trimmed = pathname.replace(/^\/+/, '');
1055
+ if (pathname === '/') return [path.join(publicPath, 'index.html')];
1056
+ const candidates = [
1057
+ path.join(publicPath, trimmed, 'index.html'),
1058
+ path.join(publicPath, trimmed),
1059
+ path.join(publicPath, `${trimmed.replace(/\/+$/, '')}.html`),
1060
+ ];
1061
+ // Prerendered pages for generateStaticParams values with special characters
1062
+ // live under the DECODED segment (`sticks & stones/`), while the request
1063
+ // pathname arrives percent-encoded (prerender-encoding suite).
1064
+ try {
1065
+ const decoded = decodeURIComponent(trimmed);
1066
+ if (decoded !== trimmed && !decoded.split('/').some(seg => seg === '..' || seg === '')) {
1067
+ candidates.push(
1068
+ path.join(publicPath, decoded, 'index.html'),
1069
+ path.join(publicPath, `${decoded.replace(/\/+$/, '')}.html`),
1070
+ );
1071
+ }
1072
+ } catch {
1073
+ // malformed escape — encoded candidates only
1074
+ }
1075
+ if (nextStaticFallback && trimmed.startsWith('_next/static/')) {
1076
+ const asset = trimmed.slice('_next/static/'.length);
1077
+ candidates.push(path.join(publicPath, 'assets', asset));
1078
+ }
1079
+ return candidates;
1080
+ }
1081
+
1082
+ // Where a lazily generated page for `pathname` is persisted (mirrors the
1083
+ // build's staticHtmlPath); null when the path would escape public/.
1084
+ function lazyStaticHtmlPath(outPath: string, pathname: string): string | null {
1085
+ const publicPath = path.join(outPath, 'public');
1086
+ const file =
1087
+ pathname === '/'
1088
+ ? path.join(publicPath, 'index.html')
1089
+ : path.join(publicPath, pathname.replace(/^\/+|\/+$/g, ''), 'index.html');
1090
+ const relative = path.relative(publicPath, file);
1091
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
1092
+ return file;
1093
+ }
1094
+
1095
+ function lazyStaticHandlerPath(outPath: string, pathname: string): string | null {
1096
+ if (pathname === '/') return null;
1097
+ const publicPath = path.join(outPath, 'public');
1098
+ const file = path.join(publicPath, pathname.replace(/^\/+|\/+$/g, ''));
1099
+ const relative = path.relative(publicPath, file);
1100
+ if (relative.startsWith('..') || path.isAbsolute(relative)) return null;
1101
+ return file;
1102
+ }
1103
+
1104
+ function isHtmlPageFile(file: string) {
1105
+ return file.endsWith('.html');
1106
+ }
1107
+
1108
+ /**
1109
+ * The SWR cache-control and x-nextjs-stale-time headers a `use cache` route's build-captured
1110
+ * cacheLife implies. A pure static HIT never re-runs the render header finalizer, so start.ts
1111
+ * re-emits these from the persisted cacheLife. Returns [] when none applies.
1112
+ */
1113
+ function cacheLifeResponseHeaders(
1114
+ life: RouteManifestEntry['cacheLife'],
1115
+ ): [string, string][] {
1116
+ if (!life) return [];
1117
+ const headers: [string, string][] = [];
1118
+ const { revalidateSeconds, expireSeconds, staleSeconds } = life;
1119
+ if (revalidateSeconds !== undefined && expireSeconds !== undefined) {
1120
+ const swr = Math.max(0, expireSeconds - revalidateSeconds);
1121
+ headers.push(['cache-control', `s-maxage=${revalidateSeconds}, stale-while-revalidate=${swr}`]);
1122
+ }
1123
+ if (staleSeconds !== undefined) headers.push(['x-nextjs-stale-time', String(staleSeconds)]);
1124
+ return headers;
1125
+ }
1126
+
1127
+ // A content-hashed, immutable build asset (client runtime chunks under
1128
+ // `assets/`, Next static files under `_next/static/`) identified by its
1129
+ // public-relative path. These are never route outputs and must be exempt from
1130
+ // revalidatePath/revalidateTag staleness, unlike prebuilt page HTML and
1131
+ // route-handler bodies. `relative` uses forward slashes (see builtFileInfo).
1132
+ function isImmutableAssetFile(relative: string) {
1133
+ return relative.startsWith('assets/') || relative.startsWith('_next/static/');
1134
+ }
1135
+
1136
+ // `dynamicParams = false` (route segment config): params governed by the
1137
+ // declaration must match one of the build's static param sets. When
1138
+ // the page has its own static params export, the route behaves like Next's
1139
+ // fallback: false — the full param tuple must match a prerendered path.
1140
+ function dynamicParamsAllowed(route: RouteManifestEntry, params: Record<string, RouteParamValue>) {
1141
+ const governed = route.segmentConfig?.dynamicParamsFalse;
1142
+ if (!governed?.length) return true;
1143
+ const allowed = route.prerenderedParams ?? [];
1144
+ const required = route.segmentConfig?.strictDynamicParams
1145
+ ? [...route.params, ...(route.catchAll ? [route.catchAll] : [])]
1146
+ : governed;
1147
+ return allowed.some(set => required.every(param => paramValueEqual(set[param], params[param])));
1148
+ }
1149
+
1150
+ function paramValueEqual(a: RouteParamValue | undefined, b: RouteParamValue | undefined) {
1151
+ if (a === undefined || b === undefined) return false;
1152
+ const join = (value: RouteParamValue) => (Array.isArray(value) ? value.join('/') : value);
1153
+ return join(a) === join(b);
1154
+ }
1155
+
1156
+ export async function maybeBuiltFile(
1157
+ outPath: string,
1158
+ pathname: string,
1159
+ staticFiles: Record<string, StaticFileMetadata> = {},
1160
+ method = 'GET',
1161
+ requestHeaders?: Headers,
1162
+ nextStaticFallback = false,
1163
+ ) {
1164
+ const normalizedMethod = method.toUpperCase();
1165
+ if (normalizedMethod !== 'GET' && normalizedMethod !== 'HEAD') return null;
1166
+
1167
+ const publicPath = path.join(outPath, 'public');
1168
+ const file = await firstFile(
1169
+ publicPath,
1170
+ builtFileCandidates(outPath, pathname, nextStaticFallback),
1171
+ );
1172
+ if (!file) return null;
1173
+ const relative = path.relative(publicPath, file).split(path.sep).join('/');
1174
+ const metadata = staticFiles[relative];
1175
+ const headers = new Headers(metadata?.headers);
1176
+ if (!headers.has('content-type')) headers.set('content-type', contentType(file));
1177
+
1178
+ const fileStat = await stat(file);
1179
+ // Vary discriminator: a router-originated fetch gets its Content-Type rewritten downstream (compat
1180
+ // swaps it to text/x-component for soft-nav requests) without this file or its etag changing. The
1181
+ // response's `Vary` header already lists `rsc` as differentiating, so the validator must differ too
1182
+ // - otherwise a browser that cached one representation gets a 304 on a later request for the OTHER
1183
+ // and silently reuses the wrong body and content-type.
1184
+ const variant = requestHeaders?.get('rsc') === '1' ? '-rsc' : '';
1185
+ const etag = `W/"${fileStat.size.toString(16)}-${Math.trunc(fileStat.mtimeMs).toString(16)}${variant}"`;
1186
+ if (!headers.has('cache-control')) {
1187
+ headers.set('cache-control', immutableAssetPath(relative) ? immutableCacheControl : 'no-cache');
1188
+ headers.set('etag', etag);
1189
+ if (requestHeaders?.get('if-none-match') === etag) {
1190
+ return new Response(null, { status: 304, headers });
1191
+ }
1192
+ }
1193
+
1194
+ const raw = await readFile(file);
1195
+ let body: BodyInit = raw;
1196
+ if (
1197
+ compressibleContentType(headers.get('content-type') ?? '') &&
1198
+ raw.length > 1024 &&
1199
+ acceptsGzip(requestHeaders)
1200
+ ) {
1201
+ const zipped = cachedGzip(`${file}:${etag}`, raw);
1202
+ if (zipped.length < raw.length) {
1203
+ body = zipped as Uint8Array<ArrayBuffer>;
1204
+ headers.set('content-encoding', 'gzip');
1205
+ headers.append('vary', 'accept-encoding');
1206
+ }
1207
+ }
1208
+
1209
+ return new Response(normalizedMethod === 'HEAD' ? null : body, {
1210
+ status: metadata?.status ?? 200,
1211
+ headers,
1212
+ });
1213
+ }
1214
+
1215
+ // Wrap dynamic responses (rendered pages, not-found) in streaming gzip.
1216
+ // Static files are compressed once and cached above; anything already
1217
+ // encoded or non-text passes through untouched.
1218
+ // Append a token to the Vary header without duplicating existing entries.
1219
+ function appendVaryToken(headers: Headers, token: string): void {
1220
+ const existing = headers.get('vary');
1221
+ if (!existing) {
1222
+ headers.set('vary', token);
1223
+ return;
1224
+ }
1225
+ const tokens = existing.split(',').map(part => part.trim().toLowerCase());
1226
+ if (tokens.includes(token.toLowerCase()) || tokens.includes('*')) return;
1227
+ headers.set('vary', `${existing}, ${token}`);
1228
+ }
1229
+
1230
+ export function compressResponse(response: Response, request: Request) {
1231
+ if (!response.body || response.status === 304 || response.status === 204) return response;
1232
+ if (response.headers.get('content-encoding')) return response;
1233
+ if (!compressibleContentType(response.headers.get('content-type') ?? '')) return response;
1234
+
1235
+ const headers = new Headers(response.headers);
1236
+ // `compress: true` advertises encoding negotiability on every compressible
1237
+ // response — even when the client didn't send Accept-Encoding — so caches key
1238
+ // on it (matches Next's compression middleware, which sets Vary before it
1239
+ // decides whether to gzip). The flight-request vary assertion expects this.
1240
+ appendVaryToken(headers, 'accept-encoding');
1241
+ if (!acceptsGzip(request.headers)) {
1242
+ return new Response(response.body, { status: response.status, headers });
1243
+ }
1244
+ // Match Next's compression middleware threshold: bodies with a known
1245
+ // length at or below 1KB are served identity-encoded (Content-Length kept).
1246
+ const knownLength = Number(headers.get('content-length'));
1247
+ if (Number.isFinite(knownLength) && knownLength <= 1024) {
1248
+ return new Response(response.body, { status: response.status, headers });
1249
+ }
1250
+ headers.set('content-encoding', 'gzip');
1251
+ headers.delete('content-length');
1252
+ return new Response(streamingGzip(response.body), {
1253
+ status: response.status,
1254
+ headers,
1255
+ });
1256
+ }
1257
+
1258
+ // Gzip a streaming response, flushing the compressor after every source chunk
1259
+ // (Z_SYNC_FLUSH) so each React/RSC flush reaches the client immediately. The Web
1260
+ // `CompressionStream('gzip')` buffers until it has a full deflate block or the
1261
+ // source ends, which stalls incremental streaming: a shell flushed before a
1262
+ // slow/suspended subtree would never reach fetch clients until the response
1263
+ // closed (next-after-app's incomplete-stream tests, and streaming Suspense in
1264
+ // general). A per-chunk sync flush keeps the stream observable at the cost of a
1265
+ // slightly worse ratio on tiny early chunks.
1266
+ function streamingGzip(body: ReadableStream<Uint8Array>): ReadableStream<Uint8Array> {
1267
+ const gzip = createGzip();
1268
+ const reader = body.getReader();
1269
+ void (async () => {
1270
+ try {
1271
+ for (;;) {
1272
+ const { done, value } = await reader.read();
1273
+ if (done) break;
1274
+ gzip.write(value);
1275
+ // Emit the bytes buffered so far as a flushable block.
1276
+ await new Promise<void>(resolve => gzip.flush(zlibConstants.Z_SYNC_FLUSH, resolve));
1277
+ }
1278
+ gzip.end();
1279
+ } catch (error) {
1280
+ gzip.destroy(error as Error);
1281
+ reader.cancel(error).catch(() => undefined);
1282
+ }
1283
+ })();
1284
+ return Readable.toWeb(gzip) as unknown as ReadableStream<Uint8Array>;
1285
+ }
1286
+
1287
+ function compressibleContentType(type: string) {
1288
+ return /^(text\/|application\/(javascript|json|manifest|xml)|image\/svg)/.test(type);
1289
+ }
1290
+
1291
+ function acceptsGzip(requestHeaders?: Headers) {
1292
+ return requestHeaders?.get('accept-encoding')?.includes('gzip') ?? false;
1293
+ }
1294
+
1295
+ // Bounded by the build's compressible assets; entries are keyed on content
1296
+ // identity (path + etag), so a rebuild naturally replaces stale ones.
1297
+ const gzipCache = new Map<string, Buffer>();
1298
+ const gzipCacheLimit = 512;
1299
+
1300
+ function cachedGzip(key: string, body: Buffer) {
1301
+ const cached = gzipCache.get(key);
1302
+ if (cached) return cached;
1303
+ const zipped = gzipSync(body, { level: 6 });
1304
+ if (gzipCache.size >= gzipCacheLimit) {
1305
+ const oldest = gzipCache.keys().next().value;
1306
+ if (oldest !== undefined) gzipCache.delete(oldest);
1307
+ }
1308
+ gzipCache.set(key, zipped);
1309
+ return zipped;
1310
+ }
1311
+
1312
+ export const immutableCacheControl = 'public, max-age=31536000, immutable';
1313
+
1314
+ // Chunk and font filenames are content-hashed, so their bytes can never change under a given URL;
1315
+ // entries, css and html keep stable names and must revalidate (no-cache still allows storing, so
1316
+ // every revisit is a cheap 304). Compat-registered static-asset prefixes are content-hashed the same
1317
+ // way, so they qualify too.
1318
+ export function immutableAssetPath(relativePath: string) {
1319
+ if (relativePath.startsWith('assets/chunks/') || relativePath.startsWith('assets/fonts/')) {
1320
+ return true;
1321
+ }
1322
+ return getAssetExtensions()
1323
+ .staticAssetPublicPrefixes()
1324
+ .some(prefix => relativePath.startsWith(prefix.replace(/^\/+/, '')));
1325
+ }
1326
+
1327
+ async function firstFile(root: string, files: string[]) {
1328
+ for (const file of files) {
1329
+ if (!isInside(root, file)) continue;
1330
+ if (!existsSync(file)) continue;
1331
+ if ((await stat(file)).isFile()) return file;
1332
+ }
1333
+ return null;
1334
+ }
1335
+
1336
+ function isInside(root: string, file: string) {
1337
+ const relative = path.relative(root, file);
1338
+ return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
1339
+ }
1340
+