@wular/pnext 0.0.18 → 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 (76) 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/config.ts +21 -12
  45. package/src/css/build.ts +153 -14
  46. package/src/css/postcss.ts +32 -3
  47. package/src/dev/restart/client-key.ts +1 -1
  48. package/src/dev/restart/global-css.ts +12 -94
  49. package/src/dev/restart/record.ts +106 -0
  50. package/src/dev/restart/route-facts.ts +11 -90
  51. package/src/dev/server.ts +208 -41
  52. package/src/extensions.ts +69 -3
  53. package/src/render/hooks.ts +1 -1
  54. package/src/render/metadata.ts +89 -17
  55. package/src/render/renderer.ts +322 -136
  56. package/src/render/resource-hints.ts +62 -9
  57. package/src/render/slots.tsx +2 -1
  58. package/src/resolve/scan-facts.ts +13 -5
  59. package/src/routing/forwarded.ts +20 -7
  60. package/src/routing/handler.ts +6 -2
  61. package/src/routing/href.ts +11 -1
  62. package/src/routing/metadata-files.ts +16 -3
  63. package/src/routing/proxy.ts +6 -3
  64. package/src/routing/routes.ts +253 -34
  65. package/src/runtime/fetch-host.ts +42 -0
  66. package/src/runtime/fingerprint.ts +132 -0
  67. package/src/runtime/loader.ts +7 -5
  68. package/src/runtime/module-cache.ts +3 -36
  69. package/src/runtime/modules.ts +333 -77
  70. package/src/runtime/vendor-build.ts +34 -5
  71. package/src/runtime/vendor.ts +1 -18
  72. package/src/types.ts +6 -0
  73. package/src/utils/asset-hash.ts +22 -0
  74. package/src/utils/code.ts +41 -0
  75. package/src/utils/html.ts +4 -1
  76. package/src/utils/serialize.ts +66 -7
@@ -13,19 +13,17 @@
13
13
  // One file PER ROUTE, not one index: the whole table is large and the first page only ever asks
14
14
  // about the route it is serving, so as a single index its JSON.parse landed, in full, on that
15
15
  // request and gave most of the walk back.
16
- import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
16
+ import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
17
17
  import path from 'node:path'
18
18
  import type { ResolvedConfig } from '../../config'
19
19
  import { setRouteFactsStore, type RouteFacts, type RouteFactsStore } from '../../routing/routes'
20
20
  import { traceEnabled } from '../../utils/trace-flags'
21
21
  import { restartCacheEnabled } from './enabled'
22
-
23
- /** `[file, mtimeMs, size, contentHash]` — the same record shape the key cache uses. */
24
- type SourceEntry = [string, number, number, string]
22
+ import { hash, lazyRecordCacheKey, revalidate, sourceEntry, type SourceEntry } from './record'
25
23
 
26
24
  interface RecordFile {
27
25
  version: number
28
- /** Everything the facts depend on that is not a walked source (see cacheKey). */
26
+ /** Everything the facts depend on that is not a walked source (see restart/record.ts). */
29
27
  key: string
30
28
  files: SourceEntry[]
31
29
  facts: RouteFacts
@@ -34,45 +32,10 @@ interface RecordFile {
34
32
  /** Bumped whenever a record's shape changes; an older record is simply ignored. */
35
33
  const RECORD_VERSION = 1
36
34
 
37
- const hash = (value: string) => Bun.hash(value).toString(36)
38
-
39
- /**
40
- * The facts of a route whose own sources did not move can still change when the
41
- * thing that *interprets* them does: the resolved config (compat on/off, route
42
- * extensions, aliases) or pnext itself. Both go in the record's key, so either
43
- * one moving makes every record read as a miss instead of half-validating it.
44
- */
45
- function cacheKey(config: ResolvedConfig): string | undefined {
46
- try {
47
- const shape = JSON.stringify(config, (_key, value: unknown) =>
48
- typeof value === 'function' ? '[fn]' : value,
49
- )
50
- return hash(`${RECORD_VERSION}\n${pnextBuildStamp()}\n${shape}`)
51
- } catch {
52
- // A config that will not serialize (cycles, exotic values) simply opts out.
53
- return undefined
54
- }
55
- }
56
-
57
- /**
58
- * mtime+size of the route scanner this process is running. The facts are the framework's own reading of
59
- * the app's sources, so a pnext that changed since the record was written must re-derive them rather than
60
- * inherit the old answers - which is what keeps this safe while pnext itself is being edited.
61
- */
62
- function pnextBuildStamp() {
63
- try {
64
- const self = Bun.fileURLToPath(import.meta.url)
65
- const scanner = path.join(path.dirname(path.dirname(self)), 'routing', 'routes.ts')
66
- const info = statSync(scanner, { throwIfNoEntry: false })
67
- return info ? `${info.mtimeMs}:${info.size}` : self
68
- } catch {
69
- return 'unknown'
70
- }
71
- }
72
-
73
35
  interface FactsCache {
74
36
  dir: string
75
- key: string
37
+ /** Resolved on first use, not at install; see restart/record.ts. */
38
+ key: () => string | undefined
76
39
  /** Records written this process, so a repeat save does not re-serialize. */
77
40
  written: Set<string>
78
41
  }
@@ -81,11 +44,9 @@ export function installRouteFactsCache(config: ResolvedConfig) {
81
44
  if (!restartCacheEnabled()) return
82
45
  // eslint-disable-next-line turbo/no-undeclared-env-vars
83
46
  if (process.env.PNEXT_DEV_FACTS_CACHE === '0') return
84
- const key = cacheKey(config)
85
- if (!key) return
86
47
  const cache: FactsCache = {
87
48
  dir: path.join(config.outPath, 'cache', 'route-facts'),
88
- key,
49
+ key: lazyRecordCacheKey(config, RECORD_VERSION),
89
50
  written: new Set(),
90
51
  }
91
52
  setRouteFactsStore(createStore(cache))
@@ -132,7 +93,7 @@ function readRecord(cache: FactsCache, routeId: string): RecordFile | undefined
132
93
  } catch {
133
94
  return undefined
134
95
  }
135
- if (parsed?.version !== RECORD_VERSION || parsed.key !== cache.key) return undefined
96
+ if (parsed?.version !== RECORD_VERSION || parsed.key !== cache.key()) return undefined
136
97
  if (!parsed.facts || !Array.isArray(parsed.files)) return undefined
137
98
  return parsed
138
99
  }
@@ -143,11 +104,13 @@ function readRecord(cache: FactsCache, routeId: string): RecordFile | undefined
143
104
  * once per process per route.
144
105
  */
145
106
  function write(cache: FactsCache, routeId: string, files: SourceEntry[], facts: RouteFacts) {
146
- if (cache.written.has(routeId)) return
107
+ const key = cache.key()
108
+ // A config that will not serialize has no key, so nothing is recorded for it.
109
+ if (!key || cache.written.has(routeId)) return
147
110
  cache.written.add(routeId)
148
111
  const contents = JSON.stringify({
149
112
  version: RECORD_VERSION,
150
- key: cache.key,
113
+ key,
151
114
  files,
152
115
  facts,
153
116
  } satisfies RecordFile)
@@ -174,48 +137,6 @@ function factsSources(facts: RouteFacts): SourceEntry[] | undefined {
174
137
  return entries
175
138
  }
176
139
 
177
- function sourceEntry(file: string): SourceEntry | undefined {
178
- try {
179
- const source = readFileSync(file, 'utf8')
180
- // Stat AFTER the read: a file that changes between the two records the
181
- // older stat, so the next boot re-hashes rather than trusting a stale one.
182
- const info = statSync(file, { throwIfNoEntry: false })
183
- if (!info) return undefined
184
- return [file, info.mtimeMs, info.size, hash(source)]
185
- } catch {
186
- return undefined
187
- }
188
- }
189
-
190
- /**
191
- * The recorded sources, re-stated: `files` itself when nothing moved, a fresh list when only stats moved,
192
- * `undefined` when any content actually differs - or a source is gone, which is the same thing, since the
193
- * walk has to run again.
194
- */
195
- function revalidate(files: SourceEntry[]): SourceEntry[] | undefined {
196
- let moved = false
197
- const checked: SourceEntry[] = []
198
- for (const entry of files) {
199
- const [file, mtimeMs, size, contentHash] = entry
200
- const info = statSync(file, { throwIfNoEntry: false })
201
- if (!info) return undefined
202
- if (info.mtimeMs === mtimeMs && info.size === size) {
203
- checked.push(entry)
204
- continue
205
- }
206
- let source: string
207
- try {
208
- source = readFileSync(file, 'utf8')
209
- } catch {
210
- return undefined
211
- }
212
- if (hash(source) !== contentHash) return undefined
213
- moved = true
214
- checked.push([file, info.mtimeMs, info.size, contentHash])
215
- }
216
- return moved ? checked : files
217
- }
218
-
219
140
  /** Bisect seam: `PNEXT_TRACE=server` prints the hit/miss split at exit. */
220
141
  const stats = { hits: 0, misses: 0 }
221
142
  if (traceEnabled('server')) {
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(' ')