@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.
Files changed (75) hide show
  1. package/README.md +2 -2
  2. package/package.json +1 -1
  3. package/reference/compat.md +2 -2
  4. package/reference/getting-started.md +2 -2
  5. package/src/cli/adapters/vercel.ts +27 -0
  6. package/src/cli/analyze.ts +8 -7
  7. package/src/cli/boot/trace.ts +9 -3
  8. package/src/cli/build.ts +23 -15
  9. package/src/cli/dev.ts +23 -0
  10. package/src/cli/migrate/report.ts +1 -1
  11. package/src/cli/migrate/run.ts +2 -2
  12. package/src/cli/migrate/scan.ts +128 -1
  13. package/src/cli/serve/pipeline.ts +42 -42
  14. package/src/cli/start.ts +4 -2
  15. package/src/client/build.ts +51 -25
  16. package/src/client/entry.ts +27 -4
  17. package/src/client/router/page-slot.ts +81 -0
  18. package/src/client/router/runtime.ts +107 -112
  19. package/src/compat/bundler/cjs-exports.ts +26 -20
  20. package/src/compat/client/css-order.ts +1 -1
  21. package/src/compat/client/navigation-scroll.ts +36 -40
  22. package/src/compat/css/modules.ts +6 -2
  23. package/src/compat/next/font/cache.ts +10 -22
  24. package/src/compat/next/font/runtime.ts +20 -0
  25. package/src/compat/next/legacy-request-apis.ts +41 -5
  26. package/src/compat/next/script.tsx +16 -24
  27. package/src/compat/react/client-lite.ts +1 -0
  28. package/src/compat/react/client.ts +11 -0
  29. package/src/compat/react/dom-client.ts +11 -2
  30. package/src/compat/react/dom-react-server.ts +4 -0
  31. package/src/compat/react/dom.ts +11 -2
  32. package/src/compat/react/internals.ts +65 -0
  33. package/src/compat/react/parity.ts +47 -0
  34. package/src/compat/react/preact.ts +31 -1
  35. package/src/compat/react/react-server.ts +7 -0
  36. package/src/compat/react/server.ts +16 -2
  37. package/src/compat/react/style-values.ts +68 -0
  38. package/src/compat/register/render.ts +14 -3
  39. package/src/compat/register/routing.ts +57 -7
  40. package/src/compat/tsconfig-defaults.ts +4 -0
  41. package/src/compat/typecheck/check.ts +26 -71
  42. package/src/compat/typecheck/worker.ts +3 -2
  43. package/src/compat-bootstrap.ts +4 -0
  44. package/src/css/build.ts +153 -14
  45. package/src/css/postcss.ts +32 -3
  46. package/src/dev/restart/client-key.ts +1 -1
  47. package/src/dev/restart/global-css.ts +12 -94
  48. package/src/dev/restart/record.ts +106 -0
  49. package/src/dev/restart/route-facts.ts +11 -90
  50. package/src/dev/server.ts +208 -41
  51. package/src/extensions.ts +69 -3
  52. package/src/render/hooks.ts +1 -1
  53. package/src/render/metadata.ts +89 -17
  54. package/src/render/renderer.ts +322 -136
  55. package/src/render/resource-hints.ts +62 -9
  56. package/src/render/slots.tsx +2 -1
  57. package/src/resolve/scan-facts.ts +13 -5
  58. package/src/routing/forwarded.ts +20 -7
  59. package/src/routing/handler.ts +6 -2
  60. package/src/routing/href.ts +11 -1
  61. package/src/routing/metadata-files.ts +16 -3
  62. package/src/routing/proxy.ts +6 -3
  63. package/src/routing/routes.ts +253 -34
  64. package/src/runtime/fetch-host.ts +42 -0
  65. package/src/runtime/fingerprint.ts +132 -0
  66. package/src/runtime/loader.ts +7 -5
  67. package/src/runtime/module-cache.ts +3 -36
  68. package/src/runtime/modules.ts +333 -77
  69. package/src/runtime/vendor-build.ts +34 -5
  70. package/src/runtime/vendor.ts +1 -18
  71. package/src/types.ts +6 -0
  72. package/src/utils/asset-hash.ts +22 -0
  73. package/src/utils/code.ts +41 -0
  74. package/src/utils/html.ts +4 -1
  75. 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 { getFontExtensions } from '../render/hooks'
37
- import { clearMetadataFileCaches, metadataRouteHandlerModule } from '../routing/metadata-files'
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: 60,
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
- return runWithWorkUnit('render', () => handleDevRequest(request, server)).finally(() => {
379
- inFlight--
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
- pageLog = pendingDevPageLoadLog(routes, request, url, pageLog?.start)
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, routes, url.pathname),
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: { 'content-type': 'text/plain' },
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
- throw error
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 cached = clientChunks.get(chunkMatch[1])
1175
- if (cached && existsSync(cached)) {
1176
- return devChunkResponse(await readFile(cached))
1177
- }
1178
-
1179
- for (const route of routes) {
1180
- if (!route.client && route.clientReferences.length === 0) continue
1181
- const cacheKey = await routeClientCacheKey(config, route)
1182
- const outDir = path.join(config.outPath, 'cache', 'client', cacheKey)
1183
- await buildDevClient(config, route)
1184
- const file = path.join(outDir, 'chunks', chunkMatch[1])
1185
- if (!isInside(path.join(outDir, 'chunks'), file) || !existsSync(file)) continue
1186
- return devChunkResponse(await readFile(file))
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
- pathname: string,
1372
+ requestPathname: string,
1267
1373
  ) {
1268
- if (!pathname.startsWith('/assets/')) return null
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: performance.now() - pending.start,
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. A
708
- * no-finalizer app (pure core) returns the response untouched.
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 {
@@ -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