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