@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.
- package/README.md +76 -20
- package/package.json +3 -2
- package/reference/data/bench.json +513 -0
- package/reference/performance.md +75 -48
- package/src/api/router/runtime.ts +60 -21
- package/src/cache/context.ts +4 -1
- package/src/cli/build.ts +37 -5
- package/src/cli/dev.ts +6 -0
- package/src/cli/index.ts +17 -3
- package/src/cli/request-pipeline.ts +1340 -0
- package/src/cli/server-entry.ts +180 -0
- package/src/cli/start.ts +39 -1311
- package/src/client/build.ts +59 -20
- package/src/client/chunk-fold.ts +40 -0
- package/src/client/compat-surface.ts +175 -0
- package/src/client/entry.ts +67 -52
- package/src/compat/actions/action-client.ts +8 -1
- package/src/compat/actions/action-dispatch.ts +11 -1
- package/src/compat/actions/discovery.ts +23 -6
- package/src/compat/bundler/optimize-package-imports.ts +5 -1
- package/src/compat/bundler/worker.ts +2 -1
- package/src/compat/client/errors/bare-boundary.ts +32 -0
- package/src/compat/client/errors/error-boundary.ts +1 -15
- package/src/compat/client/errors/primitive-throw.ts +16 -0
- package/src/compat/client/link-status.ts +1 -1
- package/src/compat/css/lightningcss.ts +2 -1
- package/src/compat/css/modules.ts +4 -3
- package/src/compat/lifecycle/instrumentation-client.ts +1 -1
- package/src/compat/lifecycle/instrumentation.ts +5 -2
- package/src/compat/next/config-loader.ts +33 -5
- package/src/compat/next/dynamic.tsx +9 -5
- package/src/compat/next/link-validation-transform.ts +5 -1
- package/src/compat/next/link.tsx +51 -58
- package/src/compat/pages/client-plugin.ts +2 -1
- package/src/compat/react/action-state.ts +159 -0
- package/src/compat/react/client-lite.ts +74 -0
- package/src/compat/react/hooks-extra.ts +92 -0
- package/src/compat/react/parity.ts +128 -0
- package/src/compat/react/preact.ts +33 -420
- package/src/compat/react/server-inserted-html.ts +14 -7
- package/src/compat/react/use.ts +72 -0
- package/src/compat/register/actions.ts +27 -7
- package/src/compat/register/segment.ts +16 -6
- package/src/config.ts +15 -1
- package/src/css/build.ts +13 -2
- package/src/dev/imports.ts +34 -5
- package/src/dev/module-cache.ts +19 -0
- package/src/dev/module-transform.ts +7 -1
- package/src/dev/server.ts +91 -22
- package/src/dynamic/source.ts +36 -27
- package/src/ppr.ts +5 -4
- package/src/proxy.ts +5 -1
- package/src/render/island-context.ts +21 -3
- package/src/render/renderer.ts +102 -26
- package/src/resolve/engine.ts +12 -2
- package/src/resolve/scan-facts.ts +239 -1
- package/src/routing/href.ts +4 -5
- package/src/routing/routes.ts +26 -41
- package/src/runtime/server.ts +8 -4
- package/src/runtime/vendor.ts +1 -1
- package/src/typegen.ts +3 -3
- package/src/utils/esbuild.ts +58 -0
- package/src/utils/fs.ts +14 -2
- 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
|
+
|