@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.
- 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/config.ts +21 -12
- 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
|
@@ -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,
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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(' ')
|