@wular/pnext 0.0.19 → 0.0.20
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 +2 -2
- package/package.json +1 -1
- package/reference/compat.md +2 -2
- package/reference/getting-started.md +2 -2
- package/src/cli/adapters/vercel.ts +27 -0
- package/src/cli/analyze.ts +8 -7
- package/src/cli/boot/trace.ts +9 -3
- package/src/cli/build.ts +23 -15
- package/src/cli/dev.ts +23 -0
- package/src/cli/migrate/report.ts +1 -1
- package/src/cli/migrate/run.ts +2 -2
- package/src/cli/migrate/scan.ts +128 -1
- package/src/cli/serve/pipeline.ts +42 -42
- package/src/cli/start.ts +4 -2
- package/src/client/build.ts +51 -25
- package/src/client/entry.ts +27 -4
- package/src/client/router/page-slot.ts +81 -0
- package/src/client/router/runtime.ts +107 -112
- package/src/compat/bundler/cjs-exports.ts +26 -20
- package/src/compat/client/css-order.ts +1 -1
- package/src/compat/client/navigation-scroll.ts +36 -40
- package/src/compat/css/modules.ts +6 -2
- package/src/compat/next/font/cache.ts +10 -22
- package/src/compat/next/font/runtime.ts +20 -0
- package/src/compat/next/legacy-request-apis.ts +41 -5
- package/src/compat/next/script.tsx +16 -24
- package/src/compat/react/client-lite.ts +1 -0
- package/src/compat/react/client.ts +11 -0
- package/src/compat/react/dom-client.ts +11 -2
- package/src/compat/react/dom-react-server.ts +4 -0
- package/src/compat/react/dom.ts +11 -2
- package/src/compat/react/internals.ts +65 -0
- package/src/compat/react/parity.ts +47 -0
- package/src/compat/react/preact.ts +31 -1
- package/src/compat/react/react-server.ts +7 -0
- package/src/compat/react/server.ts +16 -2
- package/src/compat/react/style-values.ts +68 -0
- package/src/compat/register/render.ts +14 -3
- package/src/compat/register/routing.ts +57 -7
- package/src/compat/tsconfig-defaults.ts +4 -0
- package/src/compat/typecheck/check.ts +26 -71
- package/src/compat/typecheck/worker.ts +3 -2
- package/src/compat-bootstrap.ts +4 -0
- package/src/css/build.ts +153 -14
- package/src/css/postcss.ts +32 -3
- package/src/dev/restart/client-key.ts +1 -1
- package/src/dev/restart/global-css.ts +12 -94
- package/src/dev/restart/record.ts +106 -0
- package/src/dev/restart/route-facts.ts +11 -90
- package/src/dev/server.ts +208 -41
- package/src/extensions.ts +69 -3
- package/src/render/hooks.ts +1 -1
- package/src/render/metadata.ts +89 -17
- package/src/render/renderer.ts +322 -136
- package/src/render/resource-hints.ts +62 -9
- package/src/render/slots.tsx +2 -1
- package/src/resolve/scan-facts.ts +13 -5
- package/src/routing/forwarded.ts +20 -7
- package/src/routing/handler.ts +6 -2
- package/src/routing/href.ts +11 -1
- package/src/routing/metadata-files.ts +16 -3
- package/src/routing/proxy.ts +6 -3
- package/src/routing/routes.ts +253 -34
- package/src/runtime/fetch-host.ts +42 -0
- package/src/runtime/fingerprint.ts +132 -0
- package/src/runtime/loader.ts +7 -5
- package/src/runtime/module-cache.ts +3 -36
- package/src/runtime/modules.ts +333 -77
- package/src/runtime/vendor-build.ts +34 -5
- package/src/runtime/vendor.ts +1 -18
- package/src/types.ts +6 -0
- package/src/utils/asset-hash.ts +22 -0
- package/src/utils/code.ts +41 -0
- package/src/utils/html.ts +4 -1
- package/src/utils/serialize.ts +66 -7
package/src/dev/server.ts
CHANGED
|
@@ -32,13 +32,22 @@ import {
|
|
|
32
32
|
serverBundleTargetForRuntime,
|
|
33
33
|
} from '../runtime/loader'
|
|
34
34
|
import { applyProxyResponse, createProxyRunner, validateProxyFiles } from '../routing/proxy'
|
|
35
|
-
import { renderGlobalNotFoundResponse, renderPageResponse } from '../render/renderer'
|
|
36
|
-
import {
|
|
37
|
-
import {
|
|
35
|
+
import { errorLogTrace, renderGlobalNotFoundResponse, renderPageResponse } from '../render/renderer'
|
|
36
|
+
import { markErrorLogged } from '../utils/error-log'
|
|
37
|
+
import { escapeHtml } from '../utils/html'
|
|
38
|
+
import { internalErrorHtml } from './internal-error'
|
|
39
|
+
import { getFontExtensions, nextCompatEnabled } from '../render/hooks'
|
|
40
|
+
import {
|
|
41
|
+
clearMetadataFileCaches,
|
|
42
|
+
discoverStaticMetadataFiles,
|
|
43
|
+
metadataRouteHandlerModule,
|
|
44
|
+
staticMetadataCacheControl,
|
|
45
|
+
} from '../routing/metadata-files'
|
|
38
46
|
import { handleRouteModule, type RouteHandlerModule } from '../routing/handler'
|
|
39
47
|
import { malformedUrlResponse, trailingSlashRedirect } from '../routing/href'
|
|
40
|
-
import { withForwardedHeaders } from '../routing/forwarded'
|
|
48
|
+
import { type PeerAddressSource, withForwardedHeaders } from '../routing/forwarded'
|
|
41
49
|
import {
|
|
50
|
+
assertNoServerActionsWithoutCompat,
|
|
42
51
|
findLayouts,
|
|
43
52
|
matchRoute,
|
|
44
53
|
materializeRouteFactsPaced,
|
|
@@ -107,6 +116,13 @@ interface DevServerOptions {
|
|
|
107
116
|
warm?: boolean
|
|
108
117
|
}
|
|
109
118
|
|
|
119
|
+
// Bun closes the socket itself at this point, so a stalled render surfaces as a
|
|
120
|
+
// bare 499; the abort log reads the same constant to attribute it.
|
|
121
|
+
const DEV_IDLE_TIMEOUT_SECONDS = 60
|
|
122
|
+
const DEV_IDLE_TIMEOUT_MS = DEV_IDLE_TIMEOUT_SECONDS * 1000
|
|
123
|
+
// Long before that, warn once that the render is still going.
|
|
124
|
+
const DEV_PAGE_STALL_WARNING_MS = 20_000
|
|
125
|
+
|
|
110
126
|
const clientBuilds = new Map<string, Promise<string>>()
|
|
111
127
|
const clientChunks = new Map<string, string>()
|
|
112
128
|
|
|
@@ -370,20 +386,28 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
370
386
|
const server = Bun.serve({
|
|
371
387
|
hostname,
|
|
372
388
|
port,
|
|
373
|
-
idleTimeout:
|
|
389
|
+
idleTimeout: DEV_IDLE_TIMEOUT_SECONDS,
|
|
374
390
|
fetch(request, server) {
|
|
375
391
|
// One work unit spans the whole request; its after-queue flushes once the
|
|
376
392
|
// response fully closes (stream end, redirect, notFound, error, abort).
|
|
377
393
|
inFlight++
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
394
|
+
// The handler's own catch covers the routing/render body. This covers the
|
|
395
|
+
// rest of it - the prologue that runs before that try, and every
|
|
396
|
+
// `return finish(...)` whose rejection a bare return hands straight past
|
|
397
|
+
// it. A rejection reaching Bun is Bun's fallback page, or a reset socket.
|
|
398
|
+
return runWithWorkUnit('render', () => handleDevRequest(request, server))
|
|
399
|
+
.catch((error: unknown) => devLastResortResponse(error, request))
|
|
400
|
+
.finally(() => {
|
|
401
|
+
inFlight--
|
|
402
|
+
})
|
|
381
403
|
},
|
|
404
|
+
// Nothing should reach here now, but Bun's own fallback is never the answer.
|
|
405
|
+
error: (error: Error) => devLastResortError(error),
|
|
382
406
|
})
|
|
383
407
|
|
|
384
408
|
async function handleDevRequest(
|
|
385
409
|
request: Request,
|
|
386
|
-
server: { timeout(request: Request, seconds: number): void },
|
|
410
|
+
server: PeerAddressSource & { timeout(request: Request, seconds: number): void },
|
|
387
411
|
): Promise<Response> {
|
|
388
412
|
const prologueStart = performance.now()
|
|
389
413
|
// The compat implementation graph loads here, on the first request, not at
|
|
@@ -394,11 +418,11 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
394
418
|
const unit = getWorkUnit()
|
|
395
419
|
const badRequest = malformedUrlResponse(request)
|
|
396
420
|
if (badRequest) return badRequest
|
|
397
|
-
request = withForwardedHeaders(request)
|
|
421
|
+
request = withForwardedHeaders(request, server)
|
|
398
422
|
let url = new URL(request.url)
|
|
399
423
|
const profile = devRequestProfile(request, url)
|
|
400
424
|
if (profile) logDevProfile(profile, 'prologue (bootstrap/headers/url)', prologueStart)
|
|
401
|
-
let pageLog = pendingDevPageLoadLog(routes, request, url)
|
|
425
|
+
let pageLog = startDevPageStallTimer(pendingDevPageLoadLog(routes, request, url))
|
|
402
426
|
// Work this request wants started, but only once its own response is out.
|
|
403
427
|
let afterResponse: (() => void) | undefined
|
|
404
428
|
const logAbort = () => {
|
|
@@ -415,7 +439,7 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
415
439
|
finalizeResponse(
|
|
416
440
|
response,
|
|
417
441
|
{ method: request.method, url: new URL(request.url), headers: request.headers },
|
|
418
|
-
{ routeKind: unit?.routeKind ?? 'html', routeMode: unit?.routeMode },
|
|
442
|
+
{ routeKind: unit?.routeKind ?? 'html', routeMode: unit?.routeMode, dev: true },
|
|
419
443
|
),
|
|
420
444
|
)
|
|
421
445
|
const logged = logDevPageResponse(pending, finalized)
|
|
@@ -446,7 +470,9 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
446
470
|
request = proxyResult.request
|
|
447
471
|
proxyResponse = proxyResult.response
|
|
448
472
|
url = new URL(request.url)
|
|
449
|
-
|
|
473
|
+
const rewrittenLog = pendingDevPageLoadLog(routes, request, url, pageLog?.start)
|
|
474
|
+
clearDevPageStallTimer(pageLog)
|
|
475
|
+
pageLog = startDevPageStallTimer(rewrittenLog)
|
|
450
476
|
|
|
451
477
|
const rewrittenPrefetchResponse = maybeDevPagePrefetchResponse(
|
|
452
478
|
routes,
|
|
@@ -468,7 +494,7 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
468
494
|
if (assetResponse) return finish(applyProxyResponse(assetResponse, proxyResponse))
|
|
469
495
|
|
|
470
496
|
const clientChunkResponse = await profileDevStep(profile, 'client chunk lookup', () =>
|
|
471
|
-
maybeDevClientChunk(config,
|
|
497
|
+
maybeDevClientChunk(config, url.pathname),
|
|
472
498
|
)
|
|
473
499
|
if (clientChunkResponse) return finish(applyProxyResponse(clientChunkResponse, proxyResponse))
|
|
474
500
|
|
|
@@ -518,6 +544,14 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
518
544
|
)
|
|
519
545
|
}
|
|
520
546
|
|
|
547
|
+
const metadataFile = await profileDevStep(profile, 'static metadata file', () =>
|
|
548
|
+
maybeStaticMetadataFile(config, url),
|
|
549
|
+
)
|
|
550
|
+
if (metadataFile) {
|
|
551
|
+
setWorkUnitRoute('static-asset')
|
|
552
|
+
return finish(applyProxyResponse(metadataFile, proxyResponse))
|
|
553
|
+
}
|
|
554
|
+
|
|
521
555
|
const staticResponse = await profileDevStep(profile, 'static lookup', () =>
|
|
522
556
|
maybeStaticFile(config.publicPath, url.pathname),
|
|
523
557
|
)
|
|
@@ -566,7 +600,10 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
566
600
|
applyProxyResponse(
|
|
567
601
|
new Response('Not Found', {
|
|
568
602
|
status: 404,
|
|
569
|
-
headers: {
|
|
603
|
+
headers: {
|
|
604
|
+
'cache-control': 'private, no-cache, no-store, max-age=0, must-revalidate',
|
|
605
|
+
'content-type': 'text/plain;charset=utf-8',
|
|
606
|
+
},
|
|
570
607
|
}),
|
|
571
608
|
proxyResponse,
|
|
572
609
|
),
|
|
@@ -636,6 +673,9 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
636
673
|
}
|
|
637
674
|
|
|
638
675
|
setWorkUnitRoute('html', matched.route.mode === 'static' ? 'static' : 'dynamic')
|
|
676
|
+
// Same refusal the build makes, at the only point dev has one: a core app
|
|
677
|
+
// with a 'use server' module gets the error document, not a dead form.
|
|
678
|
+
assertNoServerActionsWithoutCompat([matched.route], nextCompatEnabled(config), config.root)
|
|
639
679
|
pageRequestsSeen++
|
|
640
680
|
noteDevCompileStart(matched.route)
|
|
641
681
|
noteDevRouteServed(config, matched.route)
|
|
@@ -710,8 +750,7 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
710
750
|
)
|
|
711
751
|
} catch (error) {
|
|
712
752
|
request.signal.removeEventListener('abort', logAbort)
|
|
713
|
-
// The error funnel (compat classifies + reports) fires from this single
|
|
714
|
-
// catch, then dev rethrows so Bun surfaces its own overlay/500.
|
|
753
|
+
// The error funnel (compat classifies + reports) fires from this single catch.
|
|
715
754
|
await reportRequestError(
|
|
716
755
|
error,
|
|
717
756
|
{ method: request.method, url: request.url, headers: request.headers },
|
|
@@ -719,7 +758,11 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
719
758
|
)
|
|
720
759
|
logDevPageError(pageLog)
|
|
721
760
|
flushWorkUnit(unit)
|
|
722
|
-
|
|
761
|
+
// Rethrowing here handed the request to Bun, which answered with its raw
|
|
762
|
+
// __bunfallback payload page (or dropped the connection outright). Nothing
|
|
763
|
+
// downstream can render this one - the render pipeline is where it came
|
|
764
|
+
// from - so dev answers with its own document, always.
|
|
765
|
+
return devLastResortResponse(error, request)
|
|
723
766
|
}
|
|
724
767
|
}
|
|
725
768
|
|
|
@@ -734,6 +777,42 @@ export async function startDevServer(options: DevServerOptions) {
|
|
|
734
777
|
return Object.assign(server, { warmup })
|
|
735
778
|
}
|
|
736
779
|
|
|
780
|
+
/**
|
|
781
|
+
* Dev's last resort: an error that escaped the render pipeline's own boundaries.
|
|
782
|
+
* Reaching here means pnext could not build a document the normal way, so the
|
|
783
|
+
* internal-error page (full trace + prefilled report) is the honest answer.
|
|
784
|
+
* Must not throw - it is the thing that runs when everything else already did.
|
|
785
|
+
*/
|
|
786
|
+
function devLastResortResponse(error: unknown, request: Request): Response {
|
|
787
|
+
let route = request.url
|
|
788
|
+
try {
|
|
789
|
+
route = new URL(request.url).pathname
|
|
790
|
+
} catch {
|
|
791
|
+
// A malformed URL is exactly the kind of request that gets here; keep the raw text.
|
|
792
|
+
}
|
|
793
|
+
return devLastResortError(error, route, request.url)
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function devLastResortError(error: unknown, route = '(unknown)', url = route): Response {
|
|
797
|
+
const resolved = error instanceof Error ? error : new Error(String(error))
|
|
798
|
+
if (markErrorLogged(resolved)) console.error(errorLogTrace(resolved))
|
|
799
|
+
let html: string
|
|
800
|
+
try {
|
|
801
|
+
html = internalErrorHtml({
|
|
802
|
+
error: resolved,
|
|
803
|
+
route,
|
|
804
|
+
url,
|
|
805
|
+
digest: (resolved as Error & { digest?: string }).digest,
|
|
806
|
+
})
|
|
807
|
+
} catch {
|
|
808
|
+
html = `<!DOCTYPE html><html><head><meta charset="utf-8"><title>pnext error</title></head><body><h1>pnext could not render this request</h1><pre>${escapeHtml(resolved.stack ?? resolved.message)}</pre></body></html>`
|
|
809
|
+
}
|
|
810
|
+
return new Response(html, {
|
|
811
|
+
status: 500,
|
|
812
|
+
headers: { 'content-type': 'text/html; charset=utf-8' },
|
|
813
|
+
})
|
|
814
|
+
}
|
|
815
|
+
|
|
737
816
|
/**
|
|
738
817
|
* The Ready banner is printed by the caller in the microtask that resolves
|
|
739
818
|
* `startDevServer`, so a timer callback is the earliest point that provably
|
|
@@ -1164,29 +1243,32 @@ async function buildDevClient(config: ResolvedConfig, route: RouteManifestEntry)
|
|
|
1164
1243
|
return build
|
|
1165
1244
|
}
|
|
1166
1245
|
|
|
1167
|
-
async function maybeDevClientChunk(
|
|
1168
|
-
config: ResolvedConfig,
|
|
1169
|
-
routes: RouteManifestEntry[],
|
|
1170
|
-
pathname: string,
|
|
1171
|
-
) {
|
|
1246
|
+
async function maybeDevClientChunk(config: ResolvedConfig, pathname: string) {
|
|
1172
1247
|
const chunkMatch = /^\/__pnext\/client\/chunks\/(.+\.js)$/.exec(pathname)
|
|
1173
1248
|
if (!chunkMatch?.[1]) return null
|
|
1174
|
-
const
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1249
|
+
const file = await resolveDevClientChunk(config, chunkMatch[1])
|
|
1250
|
+
return file ? devChunkResponse(await readFile(file)) : null
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// A chunk only exists because some build emitted it, so a miss never justifies building: wait out
|
|
1254
|
+
// the in-flight builds, then look across the generations on disk (chunk names are content hashes).
|
|
1255
|
+
// Building instead cost one full client build per route for a single stale chunk URL.
|
|
1256
|
+
async function resolveDevClientChunk(config: ResolvedConfig, name: string) {
|
|
1257
|
+
const indexed = clientChunks.get(name)
|
|
1258
|
+
if (indexed && existsSync(indexed)) return indexed
|
|
1259
|
+
await Promise.allSettled([...clientBuilds.values()])
|
|
1260
|
+
const settled = clientChunks.get(name)
|
|
1261
|
+
if (settled && existsSync(settled)) return settled
|
|
1262
|
+
|
|
1263
|
+
const clientRoot = path.join(config.outPath, 'cache', 'client')
|
|
1264
|
+
for (const generation of await readdir(clientRoot).catch(() => [])) {
|
|
1265
|
+
const chunksDir = path.join(clientRoot, generation, 'chunks')
|
|
1266
|
+
const file = path.join(chunksDir, name)
|
|
1267
|
+
if (!isInside(chunksDir, file) || !existsSync(file)) continue
|
|
1268
|
+
clientChunks.set(name, file)
|
|
1269
|
+
return file
|
|
1187
1270
|
}
|
|
1188
|
-
|
|
1189
|
-
return null
|
|
1271
|
+
return undefined
|
|
1190
1272
|
}
|
|
1191
1273
|
|
|
1192
1274
|
/**
|
|
@@ -1221,6 +1303,20 @@ async function indexClientChunks(outDir: string) {
|
|
|
1221
1303
|
}
|
|
1222
1304
|
}
|
|
1223
1305
|
|
|
1306
|
+
// File-convention metadata assets (app/icon.png, apple-icon.png, favicon.ico, opengraph-image.png)
|
|
1307
|
+
// are emitted by the BUILD, so dev - which has no build output - served none of them and every dev
|
|
1308
|
+
// document 404'd the very icon/manifest links it had just rendered. Serve the source bytes at the
|
|
1309
|
+
// URL the document references. Icon/apple-icon queries carry a content identity;
|
|
1310
|
+
// favicon and manifest use their documented bare routes.
|
|
1311
|
+
async function maybeStaticMetadataFile(config: ResolvedConfig, url: URL) {
|
|
1312
|
+
const target = url.pathname.replace(/^\/+/, '')
|
|
1313
|
+
const file = discoverStaticMetadataFiles(config.appPath).find(item => item.outputPath === target)
|
|
1314
|
+
if (!file || !existsSync(file.file)) return null
|
|
1315
|
+
return new Response(await readFile(file.file), {
|
|
1316
|
+
headers: { 'content-type': file.contentType, 'cache-control': staticMetadataCacheControl },
|
|
1317
|
+
})
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1224
1320
|
async function maybeStaticFile(publicPath: string, pathname: string) {
|
|
1225
1321
|
const filePath = path.join(publicPath, pathname.replace(/^\/+/, ''))
|
|
1226
1322
|
if (!isInside(publicPath, filePath) || !existsSync(filePath)) return null
|
|
@@ -1260,12 +1356,23 @@ function findClientReferenceCss(routes: RouteManifestEntry[], id: string) {
|
|
|
1260
1356
|
.find(reference => reference.id === id && reference.cssImports?.length)
|
|
1261
1357
|
}
|
|
1262
1358
|
|
|
1359
|
+
// Build assets are emitted under `/_next/static/` for a compat app (assetPathname) and `/assets/`
|
|
1360
|
+
// for core; the dev server answers to both spellings so the document's href and the served route
|
|
1361
|
+
// cannot drift. Only a flat name aliases - `/_next/static/chunks|media/*` are Next's own paths and
|
|
1362
|
+
// keep falling through to their handlers.
|
|
1363
|
+
function builtAssetPathname(config: ResolvedConfig, pathname: string) {
|
|
1364
|
+
if (pathname.startsWith('/assets/')) return pathname
|
|
1365
|
+
const alias = /^\/_next\/static\/([^/]+)$/.exec(pathname)
|
|
1366
|
+
return alias?.[1] && nextCompatEnabled(config) ? `/assets/${alias[1]}` : null
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1263
1369
|
async function maybeBuiltAsset(
|
|
1264
1370
|
config: ResolvedConfig,
|
|
1265
1371
|
routes: RouteManifestEntry[],
|
|
1266
|
-
|
|
1372
|
+
requestPathname: string,
|
|
1267
1373
|
) {
|
|
1268
|
-
|
|
1374
|
+
const pathname = builtAssetPathname(config, requestPathname)
|
|
1375
|
+
if (!pathname) return null
|
|
1269
1376
|
if (pathname === '/assets/global.css') {
|
|
1270
1377
|
await buildDevAsset(config, pathname, () => buildGlobalCss(config, { dev: true }))
|
|
1271
1378
|
} else {
|
|
@@ -1352,6 +1459,7 @@ interface PendingDevPageLoadLog {
|
|
|
1352
1459
|
pathname: string
|
|
1353
1460
|
route: string
|
|
1354
1461
|
start: number
|
|
1462
|
+
stallTimer?: ReturnType<typeof setTimeout>
|
|
1355
1463
|
}
|
|
1356
1464
|
|
|
1357
1465
|
interface DevPageLoadLog {
|
|
@@ -1360,6 +1468,7 @@ interface DevPageLoadLog {
|
|
|
1360
1468
|
route: string
|
|
1361
1469
|
status: number
|
|
1362
1470
|
durationMs: number
|
|
1471
|
+
note?: string
|
|
1363
1472
|
}
|
|
1364
1473
|
|
|
1365
1474
|
function pendingDevPageLoadLog(
|
|
@@ -1378,8 +1487,61 @@ function pendingDevPageLoadLog(
|
|
|
1378
1487
|
}
|
|
1379
1488
|
}
|
|
1380
1489
|
|
|
1490
|
+
// One timer per page request: if the render never resolves, the stall announces
|
|
1491
|
+
// itself well before Bun's idle timeout turns it into a mystery 499.
|
|
1492
|
+
function startDevPageStallTimer(pending: PendingDevPageLoadLog | undefined) {
|
|
1493
|
+
if (!pending) return pending
|
|
1494
|
+
const delay = Math.max(0, DEV_PAGE_STALL_WARNING_MS - (performance.now() - pending.start))
|
|
1495
|
+
pending.stallTimer = setTimeout(() => {
|
|
1496
|
+
console.warn(
|
|
1497
|
+
formatDevPageStallWarning({
|
|
1498
|
+
method: pending.method,
|
|
1499
|
+
pathname: pending.pathname,
|
|
1500
|
+
route: pending.route,
|
|
1501
|
+
elapsedMs: performance.now() - pending.start,
|
|
1502
|
+
}),
|
|
1503
|
+
)
|
|
1504
|
+
}, delay)
|
|
1505
|
+
pending.stallTimer.unref?.()
|
|
1506
|
+
return pending
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
function clearDevPageStallTimer(pending: PendingDevPageLoadLog | undefined) {
|
|
1510
|
+
if (!pending?.stallTimer) return
|
|
1511
|
+
clearTimeout(pending.stallTimer)
|
|
1512
|
+
pending.stallTimer = undefined
|
|
1513
|
+
}
|
|
1514
|
+
|
|
1515
|
+
export function formatDevPageStallWarning(entry: {
|
|
1516
|
+
method: string
|
|
1517
|
+
pathname: string
|
|
1518
|
+
route: string
|
|
1519
|
+
elapsedMs: number
|
|
1520
|
+
}) {
|
|
1521
|
+
const route = entry.route === entry.pathname ? '' : dim(`(${entry.route})`)
|
|
1522
|
+
return [
|
|
1523
|
+
yellow('⚠'),
|
|
1524
|
+
dim('page'),
|
|
1525
|
+
dim(entry.method),
|
|
1526
|
+
cyan(entry.pathname),
|
|
1527
|
+
route,
|
|
1528
|
+
yellow('still rendering'),
|
|
1529
|
+
dim('after'),
|
|
1530
|
+
durationLabel(entry.elapsedMs),
|
|
1531
|
+
dim('- no response yet'),
|
|
1532
|
+
]
|
|
1533
|
+
.filter(Boolean)
|
|
1534
|
+
.join(' ')
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// A 499 that lands on the idle timeout is Bun hanging up on us, not the client.
|
|
1538
|
+
export function devPageAbortNote(durationMs: number) {
|
|
1539
|
+
return durationMs >= DEV_IDLE_TIMEOUT_MS ? 'dev server idle timeout' : undefined
|
|
1540
|
+
}
|
|
1541
|
+
|
|
1381
1542
|
function logDevPageResponse(pending: PendingDevPageLoadLog | undefined, response: Response) {
|
|
1382
1543
|
if (!pending) return response
|
|
1544
|
+
clearDevPageStallTimer(pending)
|
|
1383
1545
|
logDevPageLoad({
|
|
1384
1546
|
method: pending.method,
|
|
1385
1547
|
pathname: pending.pathname,
|
|
@@ -1392,6 +1554,7 @@ function logDevPageResponse(pending: PendingDevPageLoadLog | undefined, response
|
|
|
1392
1554
|
|
|
1393
1555
|
function logDevPageError(pending: PendingDevPageLoadLog | undefined) {
|
|
1394
1556
|
if (!pending) return
|
|
1557
|
+
clearDevPageStallTimer(pending)
|
|
1395
1558
|
logDevPageLoad({
|
|
1396
1559
|
method: pending.method,
|
|
1397
1560
|
pathname: pending.pathname,
|
|
@@ -1403,12 +1566,15 @@ function logDevPageError(pending: PendingDevPageLoadLog | undefined) {
|
|
|
1403
1566
|
|
|
1404
1567
|
function logDevPageAbort(pending: PendingDevPageLoadLog | undefined) {
|
|
1405
1568
|
if (!pending) return
|
|
1569
|
+
clearDevPageStallTimer(pending)
|
|
1570
|
+
const durationMs = performance.now() - pending.start
|
|
1406
1571
|
logDevPageLoad({
|
|
1407
1572
|
method: pending.method,
|
|
1408
1573
|
pathname: pending.pathname,
|
|
1409
1574
|
route: pending.route,
|
|
1410
1575
|
status: 499,
|
|
1411
|
-
durationMs
|
|
1576
|
+
durationMs,
|
|
1577
|
+
note: devPageAbortNote(durationMs),
|
|
1412
1578
|
})
|
|
1413
1579
|
}
|
|
1414
1580
|
|
|
@@ -1443,6 +1609,7 @@ export function formatDevPageLoadLog(entry: DevPageLoadLog) {
|
|
|
1443
1609
|
statusLabel(entry.status),
|
|
1444
1610
|
dim('in'),
|
|
1445
1611
|
durationLabel(entry.durationMs),
|
|
1612
|
+
entry.note ? yellow(`(${entry.note})`) : '',
|
|
1446
1613
|
]
|
|
1447
1614
|
.filter(Boolean)
|
|
1448
1615
|
.join(' ')
|
package/src/extensions.ts
CHANGED
|
@@ -376,6 +376,17 @@ export interface RenderExtensions {
|
|
|
376
376
|
context: VaryParamsTrackingContext,
|
|
377
377
|
) => T
|
|
378
378
|
|
|
379
|
+
/**
|
|
380
|
+
* Give a `params`/`searchParams` promise Next 15's transitional SYNC surface (the resolved keys
|
|
381
|
+
* readable without `await`), behind compat.next.legacyRequestAPIs. The SAME promise comes back —
|
|
382
|
+
* callers hand a settled thenable to `use()`. Default: identity.
|
|
383
|
+
*/
|
|
384
|
+
legacySyncProps: <T extends object>(
|
|
385
|
+
promise: Promise<T>,
|
|
386
|
+
kind: 'params' | 'searchParams',
|
|
387
|
+
value: T,
|
|
388
|
+
) => Promise<T>
|
|
389
|
+
|
|
379
390
|
/** Optional opaque state lifecycle around partial prerenders. */
|
|
380
391
|
prerenderSidecar: PrerenderSidecarExtension
|
|
381
392
|
}
|
|
@@ -476,6 +487,7 @@ function createRenderExtensions(): RenderExtensions {
|
|
|
476
487
|
formatErrorLog: () => undefined,
|
|
477
488
|
globalNotFoundEnabled: () => false,
|
|
478
489
|
trackVaryParams: value => value,
|
|
490
|
+
legacySyncProps: promise => promise,
|
|
479
491
|
prerenderSidecar: {
|
|
480
492
|
begin: () => undefined,
|
|
481
493
|
collect: () => Promise.resolve(undefined),
|
|
@@ -704,8 +716,11 @@ export function registerRevalidateBypassToken(token: () => string | undefined):
|
|
|
704
716
|
* Run every registered response finalizer against `response` before it is
|
|
705
717
|
* flushed, then return the response to send. Finalizers see request info + the
|
|
706
718
|
* outgoing status/headers and mutate them in place; when a finalizer changes the
|
|
707
|
-
* status (immutable on a Response) the body is re-wrapped with the new status.
|
|
708
|
-
*
|
|
719
|
+
* status (immutable on a Response) the body is re-wrapped with the new status.
|
|
720
|
+
*
|
|
721
|
+
* The document cache-control default is applied LAST, and only when nothing
|
|
722
|
+
* upstream claimed the header: an ISR/`use cache` route's SWR value, a route
|
|
723
|
+
* handler's own header and compat's RSC `private, no-store` all win over it.
|
|
709
724
|
*/
|
|
710
725
|
export async function finalizeResponse(
|
|
711
726
|
response: Response,
|
|
@@ -714,10 +729,10 @@ export async function finalizeResponse(
|
|
|
714
729
|
routeKind: ResponseRouteKind
|
|
715
730
|
routeMode?: ResponseRouteMode
|
|
716
731
|
hints?: Record<string, unknown>
|
|
732
|
+
dev?: boolean
|
|
717
733
|
},
|
|
718
734
|
): Promise<Response> {
|
|
719
735
|
const finalizers = activeHost.request.responseFinalizers
|
|
720
|
-
if (finalizers.length === 0) return response
|
|
721
736
|
const ctx: ResponseFinalizerContext = {
|
|
722
737
|
request,
|
|
723
738
|
routeKind: info.routeKind,
|
|
@@ -727,6 +742,7 @@ export async function finalizeResponse(
|
|
|
727
742
|
headers: response.headers,
|
|
728
743
|
}
|
|
729
744
|
for (const finalizer of finalizers) await finalizer(ctx)
|
|
745
|
+
applyDocumentCacheControl(ctx, info.dev === true)
|
|
730
746
|
if (ctx.status === response.status) return response
|
|
731
747
|
return new Response(response.body, {
|
|
732
748
|
status: ctx.status,
|
|
@@ -735,6 +751,56 @@ export async function finalizeResponse(
|
|
|
735
751
|
})
|
|
736
752
|
}
|
|
737
753
|
|
|
754
|
+
/**
|
|
755
|
+
* What Next answers a DOCUMENT with, byte for byte (measured against stock
|
|
756
|
+
* next@16.2.12, `next start` and `next dev`; see tests/compat/conformance):
|
|
757
|
+
*
|
|
758
|
+
* dev, every document `no-cache, must-revalidate`
|
|
759
|
+
* prod, per-request document `private, no-cache, no-store, max-age=0, must-revalidate`
|
|
760
|
+
* prod, pure prerender (200) `s-maxage=31536000`
|
|
761
|
+
* prod, ISR / `use cache` `s-maxage=<revalidate>, stale-while-revalidate=<expire-revalidate>`
|
|
762
|
+
* route handler nothing, unless it sets `revalidate`
|
|
763
|
+
*
|
|
764
|
+
* Without one of these an intermediary is free to apply heuristic freshness to
|
|
765
|
+
* a personalised document. A non-200 is never a cacheable prerender in Next
|
|
766
|
+
* (its prerendered 404 still answers with the per-request value), so status
|
|
767
|
+
* gates the static case rather than the route's build-time disposition alone —
|
|
768
|
+
* which is also why no 404/500 render spells its own `no-store` any more: the
|
|
769
|
+
* prod value below already carries it, in Next's words.
|
|
770
|
+
*/
|
|
771
|
+
const DYNAMIC_DOCUMENT_CACHE_CONTROL = 'private, no-cache, no-store, max-age=0, must-revalidate'
|
|
772
|
+
const DEV_DOCUMENT_CACHE_CONTROL = 'no-cache, must-revalidate'
|
|
773
|
+
const DOCUMENT_EXPIRE_SECONDS = 31536000
|
|
774
|
+
|
|
775
|
+
function applyDocumentCacheControl(ctx: ResponseFinalizerContext, dev: boolean): void {
|
|
776
|
+
if (ctx.routeKind !== 'html') return
|
|
777
|
+
if (ctx.headers.has('cache-control')) return
|
|
778
|
+
if (!(ctx.headers.get('content-type') ?? '').startsWith('text/html')) return
|
|
779
|
+
if (dev) {
|
|
780
|
+
ctx.headers.set('cache-control', DEV_DOCUMENT_CACHE_CONTROL)
|
|
781
|
+
return
|
|
782
|
+
}
|
|
783
|
+
if (ctx.status !== 200) {
|
|
784
|
+
ctx.headers.set('cache-control', DYNAMIC_DOCUMENT_CACHE_CONTROL)
|
|
785
|
+
return
|
|
786
|
+
}
|
|
787
|
+
// Compat sets the SWR value itself (its expireTime is configurable and it also
|
|
788
|
+
// covers pages-router getStaticProps); this only catches a core ISR document,
|
|
789
|
+
// which would otherwise be told never to cache a page that is cacheable for N.
|
|
790
|
+
const revalidate = ctx.routeMode === 'isr' ? ctx.hints?.revalidateSeconds : undefined
|
|
791
|
+
if (typeof revalidate === 'number' && revalidate > 0) {
|
|
792
|
+
const swr = Math.max(0, DOCUMENT_EXPIRE_SECONDS - revalidate)
|
|
793
|
+
ctx.headers.set('cache-control', `s-maxage=${revalidate}, stale-while-revalidate=${swr}`)
|
|
794
|
+
return
|
|
795
|
+
}
|
|
796
|
+
ctx.headers.set(
|
|
797
|
+
'cache-control',
|
|
798
|
+
ctx.routeMode === 'static'
|
|
799
|
+
? `s-maxage=${DOCUMENT_EXPIRE_SECONDS}`
|
|
800
|
+
: DYNAMIC_DOCUMENT_CACHE_CONTROL,
|
|
801
|
+
)
|
|
802
|
+
}
|
|
803
|
+
|
|
738
804
|
export function setRequestExtensions(
|
|
739
805
|
overrides: Partial<Omit<RequestExtensions, 'interceptors' | 'responseFinalizers'>>,
|
|
740
806
|
): void {
|
package/src/render/hooks.ts
CHANGED
|
@@ -22,7 +22,7 @@ import { restoreWithExtensionHost } from '../extensions'
|
|
|
22
22
|
// ---------------------------------------------------------------------------
|
|
23
23
|
|
|
24
24
|
/** Next compat is on (server actions, next/* modules, authInterrupts). */
|
|
25
|
-
export function nextCompatEnabled(config: ResolvedConfig): boolean {
|
|
25
|
+
export function nextCompatEnabled(config: Pick<ResolvedConfig, 'compat'>): boolean {
|
|
26
26
|
return Boolean(config.compat?.next)
|
|
27
27
|
}
|
|
28
28
|
|