@wular/pnext 0.0.19 → 0.0.21

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 (83) 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/cache.ts +324 -0
  9. package/src/cli/build.ts +42 -18
  10. package/src/cli/create.ts +4 -4
  11. package/src/cli/dev.ts +23 -0
  12. package/src/cli/migrate/package-json.ts +3 -2
  13. package/src/cli/migrate/report.ts +1 -1
  14. package/src/cli/migrate/run.ts +2 -2
  15. package/src/cli/migrate/scan.ts +128 -1
  16. package/src/cli/serve/pipeline.ts +42 -42
  17. package/src/cli/start.ts +20 -20
  18. package/src/client/build.ts +64 -35
  19. package/src/client/entry.ts +228 -16
  20. package/src/client/router/page-slot.ts +81 -0
  21. package/src/client/router/runtime.ts +107 -112
  22. package/src/compat/bundler/cjs-exports.ts +26 -20
  23. package/src/compat/client/css-order.ts +1 -1
  24. package/src/compat/client/navigation-scroll.ts +36 -40
  25. package/src/compat/css/modules.ts +6 -2
  26. package/src/compat/metadata.ts +0 -9
  27. package/src/compat/next/font/cache.ts +10 -22
  28. package/src/compat/next/font/runtime.ts +20 -0
  29. package/src/compat/next/legacy-request-apis.ts +41 -5
  30. package/src/compat/next/script.tsx +16 -24
  31. package/src/compat/react/client-lite.ts +1 -0
  32. package/src/compat/react/client.ts +11 -0
  33. package/src/compat/react/dom-client.ts +11 -2
  34. package/src/compat/react/dom-react-server.ts +4 -0
  35. package/src/compat/react/dom.ts +11 -2
  36. package/src/compat/react/internals.ts +65 -0
  37. package/src/compat/react/parity.ts +47 -0
  38. package/src/compat/react/preact.ts +31 -1
  39. package/src/compat/react/react-server.ts +7 -0
  40. package/src/compat/react/server.ts +16 -2
  41. package/src/compat/react/style-values.ts +68 -0
  42. package/src/compat/register/proxy.ts +3 -1
  43. package/src/compat/register/render.ts +14 -3
  44. package/src/compat/register/routing.ts +57 -7
  45. package/src/compat/register/typed-routes.ts +0 -2
  46. package/src/compat/tsconfig-defaults.ts +4 -0
  47. package/src/compat/typecheck/check.ts +26 -71
  48. package/src/compat/typecheck/worker.ts +3 -2
  49. package/src/compat/validation/validate.ts +2 -0
  50. package/src/compat-bootstrap.ts +4 -0
  51. package/src/css/build.ts +153 -14
  52. package/src/css/postcss.ts +32 -3
  53. package/src/dev/restart/client-key.ts +1 -1
  54. package/src/dev/restart/global-css.ts +12 -94
  55. package/src/dev/restart/record.ts +106 -0
  56. package/src/dev/restart/route-facts.ts +11 -90
  57. package/src/dev/server.ts +208 -41
  58. package/src/extensions.ts +69 -3
  59. package/src/render/hooks.ts +1 -1
  60. package/src/render/metadata.ts +89 -17
  61. package/src/render/renderer.ts +325 -138
  62. package/src/render/resource-hints.ts +62 -9
  63. package/src/render/slots.tsx +2 -1
  64. package/src/resolve/scan-facts.ts +13 -5
  65. package/src/routing/forwarded.ts +20 -7
  66. package/src/routing/handler.ts +6 -2
  67. package/src/routing/href.ts +11 -1
  68. package/src/routing/metadata-files.ts +16 -3
  69. package/src/routing/proxy.ts +6 -3
  70. package/src/routing/routes.ts +253 -34
  71. package/src/runtime/fetch-host.ts +42 -0
  72. package/src/runtime/fingerprint.ts +132 -0
  73. package/src/runtime/loader.ts +7 -5
  74. package/src/runtime/module-cache.ts +3 -36
  75. package/src/runtime/modules.ts +333 -77
  76. package/src/runtime/vendor-build.ts +34 -5
  77. package/src/runtime/vendor.ts +1 -18
  78. package/src/types.ts +6 -0
  79. package/src/utils/asset-hash.ts +22 -0
  80. package/src/utils/code.ts +41 -0
  81. package/src/utils/html.ts +4 -1
  82. package/src/utils/registry.ts +70 -0
  83. package/src/utils/serialize.ts +66 -7
package/src/cli/create.ts CHANGED
@@ -2,7 +2,7 @@ import { existsSync } from 'node:fs'
2
2
  import { mkdir, readdir } from 'node:fs/promises'
3
3
  import path from 'node:path'
4
4
  import { bold, cyan, dim, green } from '../utils/ansi'
5
- import { pnextVersionRange } from '../utils/fs'
5
+ import { latestPnextVersionRange } from '../utils/registry'
6
6
  import { formatDuration } from '../utils/verbose'
7
7
 
8
8
  export interface CreateAppOptions {
@@ -27,7 +27,7 @@ export async function createApp(dir: string | undefined, options: CreateAppOptio
27
27
  const name = sanitizePackageName(path.basename(target))
28
28
  console.log(`${cyan('▲')} ${bold('pnext create')} ${dim(`— scaffolding ${name}`)}\n`)
29
29
 
30
- const files = scaffoldFiles(name)
30
+ const files = scaffoldFiles(name, await latestPnextVersionRange('create'))
31
31
  await Promise.all(
32
32
  Object.entries(files).map(([file, source]) => Bun.write(path.join(target, file), source)),
33
33
  )
@@ -112,7 +112,7 @@ function printNextSteps(dir: string, installed: boolean) {
112
112
  console.log(dim(`└${'─'.repeat(width + 2)}┘`))
113
113
  }
114
114
 
115
- function scaffoldFiles(name: string): Record<string, string> {
115
+ function scaffoldFiles(name: string, pnextRange: string): Record<string, string> {
116
116
  return {
117
117
  'package.json': `${JSON.stringify(
118
118
  {
@@ -126,7 +126,7 @@ function scaffoldFiles(name: string): Record<string, string> {
126
126
  },
127
127
  dependencies: { preact: '^10' },
128
128
  devDependencies: {
129
- '@wular/pnext': pnextVersionRange(),
129
+ '@wular/pnext': pnextRange,
130
130
  typescript: '^5',
131
131
  '@types/bun': '^1',
132
132
  },
package/src/cli/dev.ts CHANGED
@@ -1,3 +1,5 @@
1
+ import { createRequire } from 'node:module'
2
+ import path from 'node:path'
1
3
  import { dim } from '../utils/ansi'
2
4
  import { moduleGenerationStats } from '../runtime/module-generations'
3
5
  import { markBoot, printBootTrace } from './boot/trace'
@@ -40,6 +42,7 @@ export async function dev(options: DevOptions = {}) {
40
42
  markBoot('dev:imports')
41
43
  const config = await loadConfig(options.root, { dev: true })
42
44
  markBoot('dev:config')
45
+ warnDuplicateSass(config.root, Boolean(config.compat?.next))
43
46
  const port = options.port ?? 3000
44
47
  const hostname = options.hostname ?? 'localhost'
45
48
  // A memory-triggered restart can briefly race the previous listener's
@@ -76,6 +79,26 @@ export async function dev(options: DevOptions = {}) {
76
79
  return server
77
80
  }
78
81
 
82
+ /** Next's dev-only preflight warning for projects carrying both Sass implementations. */
83
+ function warnDuplicateSass(root: string, nextCompat: boolean): void {
84
+ if (!nextCompat) return
85
+ const appRequire = createRequire(path.join(root, 'package.json'))
86
+ const installed = (name: string) => {
87
+ try {
88
+ appRequire.resolve(name)
89
+ return true
90
+ } catch {
91
+ return false
92
+ }
93
+ }
94
+ if (!installed('sass') || !installed('node-sass')) return
95
+ console.warn(
96
+ 'Your project has both `sass` and `node-sass` installed as dependencies, but should only use one or the other. ' +
97
+ 'Please remove the `node-sass` dependency from your project. ' +
98
+ ' Read more: https://nextjs.org/docs/messages/duplicate-sass',
99
+ )
100
+ }
101
+
79
102
  // Every dev reload imports changed modules under fresh URLs, and the ES module
80
103
  // registry never evicts, so a long session pins every stale graph in memory.
81
104
  // Once RSS crosses the limit, trade a seconds-long restart (vendor bundles are
@@ -3,7 +3,8 @@
3
3
 
4
4
  import { readFile, writeFile } from 'node:fs/promises'
5
5
  import path from 'node:path'
6
- import { pnextOptionalDependencyRange, pnextVersionRange } from '../../utils/fs'
6
+ import { pnextOptionalDependencyRange } from '../../utils/fs'
7
+ import { latestPnextVersionRange } from '../../utils/registry'
7
8
  import type { MigrationResult } from './report'
8
9
 
9
10
  const SEPARATORS = new Set(['&&', '||', ';', '|', '&'])
@@ -124,7 +125,7 @@ export async function migratePackageJson(
124
125
  }
125
126
  }
126
127
  if (!('@wular/pnext' in devDependencies) && !('@wular/pnext' in dependencies)) {
127
- const versionRange = pnextVersionRange()
128
+ const versionRange = await latestPnextVersionRange('migrate')
128
129
  devDependencies['@wular/pnext'] = versionRange
129
130
  result.edits.push({
130
131
  file: 'package.json',
@@ -25,7 +25,7 @@ export interface ReportItem {
25
25
  export interface MigrationResult {
26
26
  edits: Edit[]
27
27
  reports: ReportItem[]
28
- /** Files reading cookies()/headers()/draftMode() synchronously (Next 14 style). */
28
+ /** Files reading cookies()/headers()/draftMode() or params/searchParams synchronously (Next 14 style). */
29
29
  syncRequestApis: string[]
30
30
  /** Files importing next/font/google, which needs the `next-font` metadata package. */
31
31
  googleFontImports: string[]
@@ -66,7 +66,7 @@ async function createPnextConfig(root: string, result: MigrationResult, dryRun:
66
66
  legacy
67
67
  ? 'export default {\n' +
68
68
  ' compat: {\n' +
69
- ' // Sync cookies()/headers() still work; migrate to `await` and drop this.\n' +
69
+ ' // Sync cookies()/headers()/params still work; migrate to `await` and drop this.\n' +
70
70
  ' next: { legacyRequestAPIs: true },\n' +
71
71
  ' },\n' +
72
72
  '};\n'
@@ -84,7 +84,7 @@ async function createPnextConfig(root: string, result: MigrationResult, dryRun:
84
84
 
85
85
  function reportSyncRequestApis(result: MigrationResult, existingConfig?: string) {
86
86
  result.reports.push({
87
- title: 'Sync cookies()/headers() detected',
87
+ title: 'Sync cookies()/headers()/params detected',
88
88
  detail: existingConfig
89
89
  ? `Set compat: { next: { legacyRequestAPIs: true } } in ${existingConfig} to keep these working, then migrate to \`await\` and remove it. See https://pnext.dev/docs/compat`
90
90
  : 'Enabled compat.next.legacyRequestAPIs; migrate to `await` and remove it. See https://pnext.dev/docs/compat',
@@ -23,6 +23,15 @@ const NEXT_HEADERS_IMPORT = /import\s*\{([^}]*)\}\s*from\s*['"]next\/headers['"]
23
23
  const REQUEST_APIS = new Set(['cookies', 'headers', 'draftMode'])
24
24
  const PROMISE_METHODS = new Set(['then', 'catch', 'finally'])
25
25
  const SPECIAL_PAGES = ['_app', '_document', '_error']
26
+ // App-router segment files, the only place `params`/`searchParams` arrive as props.
27
+ const SEGMENT_FILES = new Set(['page', 'layout', 'route', 'template', 'default'])
28
+ // Every head whose match ENDS at the parameter list's `(` — default exports and generateMetadata.
29
+ const SEGMENT_HEADS =
30
+ /export\s+default\s+(?:async\s+)?function\s*[\w$]*\s*\(|export\s+default\s+(?:async\s+)?\(|export\s+(?:async\s+)?function\s+generateMetadata\s*\(|export\s+const\s+generateMetadata\s*(?::[^=]*)?=\s*(?:async\s*)?\(/g
31
+ // Route-handler methods take `{ params }` as their SECOND parameter (the context object).
32
+ const ROUTE_HANDLER_HEADS =
33
+ /export\s+(?:async\s+)?function\s+(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s*\(|export\s+const\s+(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS)\s*(?::[^=]*)?=\s*(?:async\s*)?\(/g
34
+ const SYNC_PROPS = ['params', 'searchParams'] as const
26
35
 
27
36
  export async function scanSources(root: string, result: MigrationResult) {
28
37
  const { SHIMMED_NEXT_DIST_PATHS } = await import('../../compat/aliases')
@@ -57,7 +66,9 @@ export async function scanSources(root: string, result: MigrationResult) {
57
66
  // package, so only it earns the dependency.
58
67
  if (specifier === 'next/font/google') result.googleFontImports.push(relative)
59
68
  }
60
- if (readsRequestApiSynchronously(text)) result.syncRequestApis.push(relative)
69
+ if (readsRequestApiSynchronously(text) || readsSyncSegmentProps(file, text)) {
70
+ result.syncRequestApis.push(relative)
71
+ }
61
72
  }
62
73
 
63
74
  if (deepImports.length > 0) {
@@ -111,6 +122,122 @@ function readsRequestApiSynchronously(text: string): boolean {
111
122
  return false
112
123
  }
113
124
 
125
+ /**
126
+ * A segment file whose default export (or generateMetadata) takes `params`/`searchParams` the Next 14
127
+ * way — annotated as a plain object rather than a Promise, or destructured and read without `await`.
128
+ * Precision over recall: an unresolvable annotation, an awaited read, or a `params` key anywhere other
129
+ * than the parameter list (an axios `{ params: { page } }` option bag) never counts.
130
+ */
131
+ export function readsSyncSegmentProps(file: string, text: string): boolean {
132
+ const extension = path.extname(file)
133
+ if (!SEGMENT_FILES.has(path.basename(file, extension))) return false
134
+ return readsHeadProps(text, SEGMENT_HEADS, 0) || readsHeadProps(text, ROUTE_HANDLER_HEADS, 1)
135
+ }
136
+
137
+ function readsHeadProps(text: string, heads: RegExp, index: number): boolean {
138
+ for (const head of text.matchAll(heads)) {
139
+ const list = balanced(text, head.index + head[0].length - 1, '(', ')')
140
+ const parameter = (splitTopLevel(list)[index] ?? '').trim()
141
+ if (parameter && readsPropsSynchronously(parameter, text)) return true
142
+ }
143
+ return false
144
+ }
145
+
146
+ function readsPropsSynchronously(parameter: string, text: string): boolean {
147
+ const colon = topLevelIndex(parameter, ':')
148
+ const binding = (colon < 0 ? parameter : parameter.slice(0, colon)).trim()
149
+ const annotation = colon < 0 ? '' : parameter.slice(colon + 1).trim()
150
+ const destructured = binding.startsWith('{') ? destructuredKeys(binding) : null
151
+ for (const prop of SYNC_PROPS) {
152
+ if (destructured && !destructured.has(prop)) continue
153
+ if (annotation) {
154
+ const declared = propertyType(annotation, text, prop)
155
+ if (declared && !/^Promise\s*</.test(declared)) return true
156
+ continue
157
+ }
158
+ // Untyped JS: a destructured binding read without `await`, or `props.params` read without one.
159
+ if (destructured) {
160
+ if (!new RegExp(`await\\s+${prop}\\b`).test(text)) return true
161
+ } else if (/^[\w$]+$/.test(binding)) {
162
+ const reads = text.matchAll(new RegExp(`(await\\s+)?\\b${binding}\\.${prop}\\b`, 'g'))
163
+ if ([...reads].some(read => !read[1])) return true
164
+ }
165
+ }
166
+ return false
167
+ }
168
+
169
+ /** The declared type of `prop` inside an inline object annotation or a locally declared type/interface. */
170
+ function propertyType(annotation: string, text: string, prop: string): string | undefined {
171
+ let body: string | undefined
172
+ if (annotation.startsWith('{')) {
173
+ body = balanced(annotation, 0, '{', '}')
174
+ } else {
175
+ const name = /^[\w$]+/.exec(annotation)?.[0]
176
+ body = name ? localTypeBody(name, text) : undefined
177
+ }
178
+ if (!body) return undefined
179
+ return new RegExp(`(?:^|[{;,\\n])\\s*${prop}\\s*\\??\\s*:\\s*([^;,\\n]+)`).exec(body)?.[1]?.trim()
180
+ }
181
+
182
+ function localTypeBody(name: string, text: string): string | undefined {
183
+ const declaration = new RegExp(`\\b(?:type|interface)\\s+${name}\\b`).exec(text)
184
+ if (!declaration) return undefined
185
+ const open = text.indexOf('{', declaration.index)
186
+ // An alias to something else (`type Props = PageProps<'/x'>`) has no object body of its own.
187
+ if (open < 0 || /[;)]/.test(text.slice(declaration.index, open))) return undefined
188
+ return balanced(text, open, '{', '}')
189
+ }
190
+
191
+ /** The keys of a destructuring pattern, renames included (`{ params: p }` → `params`). */
192
+ function destructuredKeys(binding: string): Set<string> {
193
+ const keys = new Set<string>()
194
+ for (const part of splitTopLevel(balanced(binding, 0, '{', '}'))) {
195
+ const key = /^\s*(?:\.\.\.)?([\w$]+)/.exec(part)?.[1]
196
+ if (key) keys.add(key)
197
+ }
198
+ return keys
199
+ }
200
+
201
+ /** Text between `open` at `from` and its matching close, exclusive. */
202
+ function balanced(text: string, from: number, open: string, close: string): string {
203
+ let depth = 0
204
+ for (let i = from; i < text.length; i++) {
205
+ if (text[i] === open) depth++
206
+ else if (text[i] === close && --depth === 0) return text.slice(from + 1, i)
207
+ }
208
+ return ''
209
+ }
210
+
211
+ function splitTopLevel(list: string): string[] {
212
+ const parts: string[] = []
213
+ let depth = 0
214
+ let start = 0
215
+ for (let i = 0; i < list.length; i++) {
216
+ const char = list[i]!
217
+ if (char === '=' && list[i + 1] === '>') i++
218
+ else if ('({[<'.includes(char)) depth++
219
+ else if (')}]>'.includes(char)) depth--
220
+ else if (char === ',' && depth === 0) {
221
+ parts.push(list.slice(start, i))
222
+ start = i + 1
223
+ }
224
+ }
225
+ parts.push(list.slice(start))
226
+ return parts
227
+ }
228
+
229
+ /** Index of `char` outside any bracket pair, or -1. */
230
+ function topLevelIndex(text: string, char: string): number {
231
+ let depth = 0
232
+ for (let i = 0; i < text.length; i++) {
233
+ const current = text[i]!
234
+ if ('({[<'.includes(current)) depth++
235
+ else if (')}]>'.includes(current)) depth--
236
+ else if (current === char && depth === 0) return i
237
+ }
238
+ return -1
239
+ }
240
+
114
241
  function scanSpecialPages(root: string, result: MigrationResult) {
115
242
  const found: string[] = []
116
243
  for (const base of ['pages', path.join('src', 'pages')]) {
@@ -32,7 +32,7 @@ import {
32
32
  malformedUrlResponse,
33
33
  trailingSlashRedirect,
34
34
  } from '../../routing/href'
35
- import { withForwardedHeaders } from '../../routing/forwarded'
35
+ import { type PeerAddressSource, withForwardedHeaders } from '../../routing/forwarded'
36
36
  import { normalizePathname, parseNavState, selectRouteForRequest } from '../../routing/routes'
37
37
  import { runWithCacheScope } from '../../request/cache'
38
38
  import { metadataRouteHandlerModule } from '../../routing/metadata-files'
@@ -57,6 +57,7 @@ import {
57
57
  } from '../../request/context'
58
58
  import { setRequestRuntime } from '../../routing/request-environment'
59
59
  import { getRenderSpanExtensions } from '../../render/hooks'
60
+ import { publishEmittedAssets } from '../../css/build'
60
61
  import { contentType } from '../../utils/content-type'
61
62
  import { stopEsbuildService } from '../../utils/esbuild'
62
63
  import { markErrorLogged } from '../../utils/error-log'
@@ -107,6 +108,9 @@ export async function createRequestHandler(
107
108
  // so runtime renders observe force-cache / revalidate TTLs / tags exactly
108
109
  // like build prerenders. No-op for pure-core apps.
109
110
  runInitHooks(config)
111
+ // Content-hashed asset names, as the build emitted them: a render in THIS
112
+ // process must link the same files, not the logical `global.css` spelling.
113
+ publishEmittedAssets(config.outPath, manifest.assetNames)
110
114
  // Publish the live routing state the compat request interceptors (action
111
115
  // dispatch, rewrites) read; prod loads the route table once.
112
116
  setRequestRuntime({ config, routes: manifest.routes, dev: false })
@@ -266,13 +270,13 @@ export async function createRequestHandler(
266
270
  // resident esbuild service child (~10+ MB RSS) — it respawns if ever needed.
267
271
  stopEsbuildService()
268
272
 
269
- return function handleRequest(request: Request): Promise<Response> {
273
+ return function handleRequest(request: Request, server?: PeerAddressSource): Promise<Response> {
270
274
  // One work unit spans the whole request; its after-queue flushes once the
271
275
  // response fully closes (stream end, redirect, notFound, error, abort).
272
276
  return runWithWorkUnit('render', async () => {
273
277
  const unit = getWorkUnit()
274
278
  try {
275
- const raw = await handle(request)
279
+ const raw = await handle(request, server)
276
280
  const finalized = await finalizeResponse(
277
281
  raw,
278
282
  { method: request.method, url: new URL(request.url), headers: request.headers },
@@ -304,10 +308,10 @@ export async function createRequestHandler(
304
308
  })
305
309
  }
306
310
 
307
- async function handle(request: Request): Promise<Response> {
311
+ async function handle(request: Request, server?: PeerAddressSource): Promise<Response> {
308
312
  const badRequest = malformedUrlResponse(request)
309
313
  if (badRequest) return badRequest
310
- request = withForwardedHeaders(request)
314
+ request = withForwardedHeaders(request, server)
311
315
  const requestedUrl = new URL(request.url)
312
316
  // assetPrefix is independent from basePath. Strip a path-style asset
313
317
  // prefix first so `/cdn/_next/static/*` remains servable even when the app
@@ -502,6 +506,18 @@ export async function createRequestHandler(
502
506
  ? { revalidateSeconds: servedBuilt.metadata.revalidateSeconds }
503
507
  : undefined,
504
508
  )
509
+ // A prebuilt DOCUMENT's cache-control belongs to the response finalizer,
510
+ // which owns the one rule (Next's static / ISR / dynamic values) for
511
+ // rendered and prebuilt pages alike. maybeBuiltFile's `no-cache` is its
512
+ // asset default, not a claim; a build-captured header is a claim and stays.
513
+ if (
514
+ isHtmlResponse(staticFile) &&
515
+ !(servedBuilt.metadata?.headers ?? []).some(
516
+ ([key]) => key.toLowerCase() === 'cache-control',
517
+ )
518
+ ) {
519
+ staticFile.headers.delete('cache-control')
520
+ }
505
521
  if (servedBuilt.metadata) {
506
522
  const servedTtl = servedBuilt.metadata.revalidateSeconds
507
523
  const expired =
@@ -523,11 +539,14 @@ export async function createRequestHandler(
523
539
  }
524
540
  }
525
541
  if (url.pathname.startsWith('/_next/static/') && !built) {
526
- setWorkUnitRoute('static-asset', 'static')
542
+ setWorkUnitRoute('static-asset')
527
543
  return applyProxyResponse(
528
544
  new Response('Not Found', {
529
545
  status: 404,
530
- headers: { 'content-type': 'text/plain' },
546
+ headers: {
547
+ 'cache-control': 'private, no-cache, no-store, max-age=0, must-revalidate',
548
+ 'content-type': 'text/plain;charset=utf-8',
549
+ },
531
550
  }),
532
551
  proxyResponse,
533
552
  )
@@ -537,20 +556,7 @@ export async function createRequestHandler(
537
556
 
538
557
  const matched = selection
539
558
  if (!matched) {
540
- return applyProxyResponse(
541
- compressResponse(
542
- await renderGlobalNotFoundResponse({
543
- config,
544
- url,
545
- request: canonicalRequest,
546
- staticMetadataFiles: manifest.staticMetadataFiles,
547
- staticModuleMetadata: manifest.staticModuleMetadata,
548
- staticRouteMetadata: manifest.staticRouteMetadata,
549
- }),
550
- request,
551
- ),
552
- proxyResponse,
553
- )
559
+ return applyProxyResponse(await renderNotFound(canonicalRequest), proxyResponse)
554
560
  }
555
561
  if (matched.route.kind === 'handler') {
556
562
  setWorkUnitRoute(
@@ -648,20 +654,7 @@ export async function createRequestHandler(
648
654
  // prerendered param set; anything else is a 404. (Prebuilt paths were
649
655
  // already served from the static output above.)
650
656
  if (!dynamicParamsAllowed(matched.route, matched.params)) {
651
- return applyProxyResponse(
652
- compressResponse(
653
- await renderGlobalNotFoundResponse({
654
- config,
655
- url,
656
- request: canonicalRequest,
657
- staticMetadataFiles: manifest.staticMetadataFiles,
658
- staticModuleMetadata: manifest.staticModuleMetadata,
659
- staticRouteMetadata: manifest.staticRouteMetadata,
660
- }),
661
- request,
662
- ),
663
- proxyResponse,
664
- )
657
+ return applyProxyResponse(await renderNotFound(canonicalRequest), proxyResponse)
665
658
  }
666
659
 
667
660
  // force-static routes render with an empty request and no search params
@@ -1324,14 +1317,21 @@ function cachedGzip(key: string, body: Buffer) {
1324
1317
 
1325
1318
  export const immutableCacheControl = 'public, max-age=31536000, immutable'
1326
1319
 
1327
- // Chunk and font filenames are content-hashed, so their bytes can never change under a given URL;
1328
- // entries, css and html keep stable names and must revalidate (no-cache still allows storing, so
1329
- // every revisit is a cheap 304). Compat-registered static-asset prefixes are content-hashed the same
1330
- // way, so they qualify too.
1320
+ /**
1321
+ * Everything the build emits under `assets/` (served at `/_next/static/*` in
1322
+ * compat) is immutable, which is the promise Next makes for that whole
1323
+ * namespace. It holds because every one of those names carries a content hash:
1324
+ * esbuild's for chunks and fonts, assetContentHash for the route entries and the
1325
+ * stylesheets (see fingerprintClientEntries / fingerprintAsset), and a build id
1326
+ * for `_next/static/<id>/_*Manifest.js`. An UNHASHED name must never reach here —
1327
+ * the same URL would answer different bytes after a deploy, and every browser
1328
+ * that saw the old ones would keep them for a year.
1329
+ *
1330
+ * Route outputs (prerendered html, handler bodies) live outside both prefixes
1331
+ * and stay revalidating; the public/ tree the app ships is likewise untouched.
1332
+ */
1331
1333
  export function immutableAssetPath(relativePath: string) {
1332
- if (relativePath.startsWith('assets/chunks/') || relativePath.startsWith('assets/fonts/')) {
1333
- return true
1334
- }
1334
+ if (relativePath.startsWith('assets/') || relativePath.startsWith('_next/static/')) return true
1335
1335
  return getAssetExtensions()
1336
1336
  .staticAssetPublicPrefixes()
1337
1337
  .some(prefix => relativePath.startsWith(prefix.replace(/^\/+/, '')))
package/src/cli/start.ts CHANGED
@@ -15,6 +15,7 @@ import { markErrorLogged } from '../utils/error-log'
15
15
  import { isPostpone } from '../render/postpone'
16
16
  import { browserHost, ensurePortFree, openBrowser, printServerReady } from './serve/ui'
17
17
  import type { BuildManifest } from '../types'
18
+ import type { PeerAddressSource } from '../routing/forwarded'
18
19
 
19
20
  interface StartOptions {
20
21
  root?: string
@@ -22,7 +23,7 @@ interface StartOptions {
22
23
  hostname?: string
23
24
  }
24
25
 
25
- type RequestHandler = (request: Request) => Promise<Response>
26
+ type RequestHandler = (request: Request, server?: PeerAddressSource) => Promise<Response>
26
27
 
27
28
  /**
28
29
  * The production request handler `pnext start` serves with. Exposed on its own
@@ -47,14 +48,9 @@ export async function start(options: StartOptions = {}) {
47
48
  const startedAt = Date.now()
48
49
  const port = options.port ?? 3000
49
50
  const hostname = options.hostname ?? 'localhost'
50
- // Export builds are not served by a production start (Next throws). Standalone builds still serve -
51
- // Next only WARNS, pointing at the standalone launcher, and the launcher re-execs this path with
52
- // `__NEXT_PRIVATE_STANDALONE_CONFIG` set to suppress that warning. Handle both before binding a
53
- // port, matching Next's messages.
51
+ // Export builds are not served; pnext serves its standalone output directly.
54
52
  const config = await guardUnservableOutputMode(options.root)
55
- const manifest = JSON.parse(
56
- await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
57
- ) as BuildManifest
53
+ const manifest = await readProductionManifest(config)
58
54
  await ensurePortFree(port, hostname)
59
55
 
60
56
  // The pipeline import starts right after listen; a request that beats it
@@ -70,7 +66,8 @@ export async function start(options: StartOptions = {}) {
70
66
  const server = Bun.serve({
71
67
  hostname,
72
68
  port,
73
- fetch: request => (handler ? handler(request) : ensureHandler().then(ready => ready(request))),
69
+ fetch: (request, server) =>
70
+ handler ? handler(request, server) : ensureHandler().then(ready => ready(request, server)),
74
71
  })
75
72
 
76
73
  printServerReady({
@@ -108,10 +105,20 @@ function installProcessErrorHandlers(): void {
108
105
  process.on('uncaughtException', report)
109
106
  }
110
107
 
111
- // Next's output-mode handling on start: an export build throws (fatal - it is unservable), while a
112
- // standalone build only warns and serves on, unless the standalone launcher re-execs with
113
- // `__NEXT_PRIVATE_STANDALONE_CONFIG` set. pnext mirrors that: load the compat config, read the output
114
- // mode from the build-compat registry, and reproduce Next's exact messages.
108
+ export async function readProductionManifest(config: ResolvedConfig): Promise<BuildManifest> {
109
+ try {
110
+ return JSON.parse(
111
+ await readFile(path.join(config.outPath, 'manifest.json'), 'utf8'),
112
+ ) as BuildManifest
113
+ } catch (error) {
114
+ if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
115
+ throw new Error("No production build found. Run 'pnext build' first.")
116
+ }
117
+ throw error
118
+ }
119
+ }
120
+
121
+ // Export output is unservable; pnext production output remains serveable for standalone apps.
115
122
  async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig> {
116
123
  const config = await loadConfig(root, { serve: true })
117
124
  await bootstrapCompat(config)
@@ -123,13 +130,6 @@ async function guardUnservableOutputMode(root?: string): Promise<ResolvedConfig>
123
130
  )
124
131
  process.exit(1)
125
132
  }
126
- // eslint-disable-next-line turbo/no-undeclared-env-vars
127
- if (mode === 'standalone' && !process.env.__NEXT_PRIVATE_STANDALONE_CONFIG) {
128
- console.warn(
129
- '"next start" does not work with "output: standalone" configuration. ' +
130
- 'Use "node .next/standalone/server.js" instead.',
131
- )
132
- }
133
133
  return config
134
134
  }
135
135