@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
@@ -1,5 +1,5 @@
1
1
  import path from 'node:path'
2
- import { existsSync, readFileSync } from 'node:fs'
2
+ import { existsSync } from 'node:fs'
3
3
  import { listFiles, listFilesSync, readDirListing, toPosixPath, withDirCache } from '../utils/fs'
4
4
  import { safeDecode } from '../utils/decode'
5
5
  import {
@@ -7,14 +7,7 @@ import {
7
7
  pnextDynamicImportNames,
8
8
  rewriteLiteralDynamicCalls,
9
9
  } from '../resolve/dynamic'
10
- import {
11
- commonJsModuleHasDefaultExport,
12
- packageNameForFile,
13
- packageNameOfSpecifier,
14
- resolveImport,
15
- resolveModuleAlias,
16
- resolvePackageSpecifier,
17
- } from '../resolve/imports'
10
+ import { resolveImport, resolveModuleAlias, resolvePackageSpecifier } from '../resolve/imports'
18
11
  import { dynamicCallFacts, scanFacts, type DynamicCallFact } from '../resolve/scan-facts'
19
12
  import { readSourceSync } from '../resolve/source-text'
20
13
  import { globalCssSourcesForPaths } from '../css/build'
@@ -251,6 +244,7 @@ export interface RouteFacts {
251
244
  clientReferences: ClientReference[]
252
245
  cssImports: string[]
253
246
  sourceFiles: string[]
247
+ serverActionFile?: string
254
248
  stream?: RouteManifestEntry['stream']
255
249
  maxDuration?: number
256
250
  ppr?: boolean
@@ -269,6 +263,7 @@ const deferredFields = [
269
263
  'clientReferences',
270
264
  'cssImports',
271
265
  'sourceFiles',
266
+ 'serverActionFile',
272
267
  'stream',
273
268
  'maxDuration',
274
269
  'ppr',
@@ -678,6 +673,7 @@ function pageOrHandlerFacts(
678
673
  clientReferences,
679
674
  cssImports,
680
675
  sourceFiles,
676
+ ...(kind === 'page' ? serverActionFileFact(context, sourceFiles) : {}),
681
677
  ...(stream ? { stream } : {}),
682
678
  maxDuration: maxDurationFromSources([source, ...layoutSources]),
683
679
  ppr: interception ? false : ppr,
@@ -1744,6 +1740,84 @@ function hasUseClientDirective(file: string, source: string) {
1744
1740
  return scanFacts(file, source).useClient
1745
1741
  }
1746
1742
 
1743
+ /**
1744
+ * The first `'use server'` module REACHABLE from this PAGE's graph - `sourceFiles`
1745
+ * is the transitive import closure of the page, its layouts and everything they
1746
+ * render, so a directive file that nothing imports is not one of these. That
1747
+ * scoping is the contract: existence never refuses an app, reachability does.
1748
+ *
1749
+ * Pages only, by the caller. An action is dangerous when it can reach a CLIENT
1750
+ * boundary, and a route handler's graph cannot - it returns a Response, never
1751
+ * markup and never an island - so a directive reachable only from a handler is
1752
+ * inert. Refusing it would break an app that works, for no user-visible reason.
1753
+ *
1754
+ * Computed regardless of compat (the parse is already memoized); only core
1755
+ * CONSUMES it - see serverActionsUnsupportedMessage.
1756
+ */
1757
+ function serverActionFileFact(context: ScanContext, sourceFiles: string[]) {
1758
+ for (const file of sourceFiles) {
1759
+ if (!fileExists(context, file) || isAssetFile(file)) continue
1760
+ if (scanFacts(file, readSource(context, file)).useServer) return { serverActionFile: file }
1761
+ }
1762
+ return {}
1763
+ }
1764
+
1765
+ /**
1766
+ * Core has no action registry and no dispatch endpoint: `'use server'` is a
1767
+ * next-compat feature. Refusing by name beats the alternative core used to have -
1768
+ * building clean and rendering a form whose `action` was silently dropped.
1769
+ *
1770
+ * Read once, act once: the file, what pnext will not do, and both remedies. No
1771
+ * stack - the CLI prints `error.message` alone (cli/index.ts).
1772
+ */
1773
+ export function serverActionsUnsupportedMessage(offender: {
1774
+ file: string
1775
+ route?: string
1776
+ root?: string
1777
+ }) {
1778
+ const file = offender.root
1779
+ ? toPosixPath(path.relative(offender.root, offender.file))
1780
+ : offender.file
1781
+ const reach = offender.route ? `, reachable from the route ${offender.route}` : ''
1782
+ return [
1783
+ `Server actions need next compat, and this app is pure core.`,
1784
+ ``,
1785
+ ` ${file} has a 'use server' directive${reach}.`,
1786
+ ``,
1787
+ `pnext will not build this app: core has no server-action registry and no`,
1788
+ `dispatch endpoint, so the action could only render as a form that submits`,
1789
+ `nowhere.`,
1790
+ ``,
1791
+ `Fix it one of two ways:`,
1792
+ ` 1. Enable compat in pnext.config.ts:`,
1793
+ ` export default { compat: { next: true } }`,
1794
+ ` 2. Or delete the 'use server' directive from ${file}, if those exports are`,
1795
+ ` only ever called on the server (in core the directive does nothing).`,
1796
+ ].join('\n')
1797
+ }
1798
+
1799
+ /**
1800
+ * Fail the build / the dev request rather than render a form whose action goes
1801
+ * nowhere. Only page routes carry the fact (see serverActionFileFact), so a
1802
+ * handler-only directive never reaches here.
1803
+ */
1804
+ export function assertNoServerActionsWithoutCompat(
1805
+ routes: RouteManifestEntry[],
1806
+ compatEnabled: boolean,
1807
+ root?: string,
1808
+ ) {
1809
+ if (compatEnabled) return
1810
+ const offender = routes.find(route => route.serverActionFile)
1811
+ if (!offender) return
1812
+ throw new Error(
1813
+ serverActionsUnsupportedMessage({
1814
+ file: offender.serverActionFile!,
1815
+ route: offender.route,
1816
+ root,
1817
+ }),
1818
+ )
1819
+ }
1820
+
1747
1821
  function isClientModule(context: ScanContext, file: string) {
1748
1822
  const key = path.resolve(file)
1749
1823
  let client = context.useClient.get(key)
@@ -1832,15 +1906,21 @@ function collectFromFile(
1832
1906
  file: string,
1833
1907
  references: Map<string, ClientReference>,
1834
1908
  visited: Set<string>,
1909
+ withinClientBoundary = false,
1835
1910
  ) {
1836
- if (visited.has(file) || !fileExists(context, file)) return
1837
- visited.add(file)
1911
+ const visitKey = `${file}\0${withinClientBoundary ? 'client' : 'server'}`
1912
+ if (visited.has(visitKey) || !fileExists(context, file)) return
1913
+ visited.add(visitKey)
1838
1914
 
1839
- if (isClientModule(context, file)) {
1915
+ const clientFile = isClientModule(context, file)
1916
+ if (clientFile) {
1840
1917
  addClientReference(references, clientReference(file, 'default'))
1841
1918
  }
1842
1919
 
1843
- for (const imported of moduleEdges(context, file)) {
1920
+ const nestedClientBoundary = withinClientBoundary || clientFile
1921
+ const imports = [...moduleEdges(context, file)]
1922
+ if (!nestedClientBoundary) imports.push(...publishedClientImports(context, file))
1923
+ for (const imported of imports) {
1844
1924
  if (isClientModule(context, imported.file)) {
1845
1925
  // `export * from './client'` re-exports the target's named exports (never
1846
1926
  // its default), so each of them is a client reference of this module.
@@ -1884,7 +1964,7 @@ function collectFromFile(
1884
1964
  )
1885
1965
  }
1886
1966
 
1887
- collectFromFile(context, imported.file, references, visited)
1967
+ collectFromFile(context, imported.file, references, visited, nestedClientBoundary)
1888
1968
  }
1889
1969
  }
1890
1970
 
@@ -2182,7 +2262,7 @@ function moduleEdges(context: ScanContext, file: string) {
2182
2262
  const key = path.resolve(file)
2183
2263
  let edges = context.edges.get(key)
2184
2264
  if (!edges) {
2185
- edges = moduleEdgesFromSource(context.root, file, readSource(context, file))
2265
+ edges = moduleEdgesFromSource(context, file, readSource(context, file))
2186
2266
  context.edges.set(key, edges)
2187
2267
  }
2188
2268
  return edges
@@ -2199,19 +2279,93 @@ function resolveModuleEdge(root: string, file: string, specifier: string) {
2199
2279
  resolveModuleAlias(specifier) ??
2200
2280
  resolveImport(root, file, specifier) ??
2201
2281
  getBundlerExtensions().resolveRouteDependency(root, file, specifier) ??
2202
- resolveScopedClientBoundaryPackage(root, file, specifier) ??
2203
2282
  getCssExtensions().resolveCssDependency(root, file, specifier)
2204
2283
  )
2205
2284
  }
2206
2285
 
2207
- const scopedClientBoundaryPackages = new Set(['@next/third-parties'])
2286
+ /** Rendered Client Component exports hidden behind a package's dist barrels. */
2287
+ function publishedClientImports(context: ScanContext, file: string) {
2288
+ const source = readSource(context, file)
2289
+ const found: ModuleEdge[] = []
2290
+ for (const edge of scanFacts(file, source).imports) {
2291
+ if (resolveModuleEdge(context.root, file, edge.specifier)) continue
2292
+ found.push(...publishedClientEdges(context, file, edge.specifier, edge.exports))
2293
+ }
2294
+ return found
2295
+ }
2208
2296
 
2209
- function resolveScopedClientBoundaryPackage(root: string, file: string, specifier: string) {
2210
- if (!scopedClientBoundaryPackages.has(packageNameOfSpecifier(specifier) ?? '')) return undefined
2211
- return resolvePackageSpecifier(root, file, specifier, ['import', 'default'])
2297
+ function publishedClientEdges(
2298
+ context: ScanContext,
2299
+ file: string,
2300
+ specifier: string,
2301
+ requested: string[],
2302
+ ) {
2303
+ const cacheKey = `${file}\0${specifier}\0${requested.join(',')}`
2304
+ const cached = context.edges.get(cacheKey)
2305
+ if (cached) return cached
2306
+ const entry = resolvePublishedEdge(context, file, specifier, 'import')
2307
+ const edges = entry ? publishedClientExports(context, entry, requested, new Set()) : []
2308
+ context.edges.set(cacheKey, edges)
2309
+ return edges
2212
2310
  }
2213
2311
 
2214
- function moduleEdgesFromSource(root: string, file: string, source: string) {
2312
+ function resolvePublishedEdge(
2313
+ context: ScanContext,
2314
+ file: string,
2315
+ specifier: string,
2316
+ kind: 'import' | 'require',
2317
+ ) {
2318
+ return (
2319
+ resolveModuleEdge(context.root, file, specifier) ??
2320
+ resolvePackageSpecifier(context.root, file, specifier, [kind, 'default'])
2321
+ )
2322
+ }
2323
+
2324
+ function publishedClientExports(
2325
+ context: ScanContext,
2326
+ file: string,
2327
+ requested: string[],
2328
+ visiting: Set<string>,
2329
+ ): ModuleEdge[] {
2330
+ const key = path.resolve(file)
2331
+ if (visiting.has(key) || !fileExists(context, key)) return []
2332
+ visiting.add(key)
2333
+ const source = readSource(context, key)
2334
+ if (hasUseClientDirective(key, source)) {
2335
+ visiting.delete(key)
2336
+ const names = [...scanFacts(key, source).exportNames, ...commonJsRouteExportNames(source)]
2337
+ const exports = requested.filter(name => names.includes(name))
2338
+ return exports.length > 0 ? [{ file: key, exports }] : []
2339
+ }
2340
+
2341
+ const facts = scanFacts(key, source)
2342
+ const found: ModuleEdge[] = []
2343
+ for (const edge of facts.imports) {
2344
+ if (!edge.reexport) continue
2345
+ const map = esmReexportMap(source, edge.specifier)
2346
+ const next = edge.star
2347
+ ? requested.filter(name => name !== 'default')
2348
+ : requested.map(name => map[name]).filter((name): name is string => Boolean(name))
2349
+ const target = resolvePublishedEdge(context, key, edge.specifier, 'import')
2350
+ if (target && next.length > 0) {
2351
+ found.push(...publishedClientExports(context, target, next, visiting))
2352
+ }
2353
+ }
2354
+ for (const edge of facts.requires) {
2355
+ const map = commonJsRequireReexportMap(source, edge.specifier)
2356
+ const next = map.star
2357
+ ? requested
2358
+ : requested.map(name => map.names[name]).filter((name): name is string => Boolean(name))
2359
+ const target = resolvePublishedEdge(context, key, edge.specifier, 'require')
2360
+ if (target && next.length > 0) {
2361
+ found.push(...publishedClientExports(context, target, next, visiting))
2362
+ }
2363
+ }
2364
+ visiting.delete(key)
2365
+ return found
2366
+ }
2367
+
2368
+ function moduleEdgesFromSource(context: ScanContext, file: string, source: string) {
2215
2369
  const dynamicNames = pnextDynamicImportNames(source, file)
2216
2370
  assertSupportedDynamicCalls(file, source, dynamicNames)
2217
2371
  // Edges carry their byte offset so the final list follows true source order:
@@ -2222,7 +2376,7 @@ function moduleEdgesFromSource(root: string, file: string, source: string) {
2222
2376
  const facts = scanFacts(file, source)
2223
2377
 
2224
2378
  for (const edge of facts.imports) {
2225
- const resolved = resolveModuleEdge(root, file, edge.specifier)
2379
+ const resolved = resolveModuleEdge(context.root, file, edge.specifier)
2226
2380
  if (!resolved) continue
2227
2381
  ordered.push({
2228
2382
  file: resolved,
@@ -2232,26 +2386,91 @@ function moduleEdgesFromSource(root: string, file: string, source: string) {
2232
2386
  })
2233
2387
  }
2234
2388
 
2235
- if (scopedClientBoundaryPackages.has(packageNameForFile(file) ?? '')) {
2236
- for (const call of facts.requires) {
2237
- const resolved = resolveModuleEdge(root, file, call.specifier)
2238
- if (!resolved) continue
2239
- const exports = commonJsModuleHasDefaultExport(readFileSync(resolved, 'utf8'), resolved)
2240
- ? ['default']
2241
- : []
2242
- ordered.push({ file: resolved, exports, index: call.index })
2243
- }
2244
- }
2245
-
2246
2389
  ordered.sort((a, b) => a.index - b.index)
2247
2390
  const imports: ModuleEdge[] = ordered.map(({ index: _index, ...edge }) => edge)
2248
2391
 
2249
- for (const imported of dynamicImportEdgesFromSource(root, file, source, dynamicNames))
2392
+ for (const imported of dynamicImportEdgesFromSource(context.root, file, source, dynamicNames))
2250
2393
  imports.push(imported)
2251
2394
 
2252
2395
  return imports
2253
2396
  }
2254
2397
 
2398
+ function esmReexportMap(source: string, specifier: string) {
2399
+ const escaped = escapeRegex(specifier)
2400
+ const names: Record<string, string> = {}
2401
+ const pattern = new RegExp(`\\bexport\\s*\\{([^}]*)\\}\\s*from\\s*["']${escaped}["']`, 'g')
2402
+ for (const clause of source.matchAll(pattern)) {
2403
+ for (const item of (clause[1] ?? '').split(',')) {
2404
+ const parts = item.trim().split(/\s+as\s+/)
2405
+ const imported = parts[0]?.trim()
2406
+ const exported = (parts[1] ?? parts[0])?.trim()
2407
+ if (imported && exported && /^[$\w]+$/.test(imported) && /^[$\w]+$/.test(exported)) {
2408
+ names[exported] = imported
2409
+ }
2410
+ }
2411
+ }
2412
+ return names
2413
+ }
2414
+
2415
+ function commonJsRequireReexportMap(source: string, specifier: string) {
2416
+ const escaped = escapeRegex(specifier)
2417
+ const names: Record<string, string> = {}
2418
+ const direct = new RegExp(
2419
+ `exports\\.([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*["']${escaped}["']\\s*\\)\\.([A-Za-z_$][\\w$]*)`,
2420
+ 'g',
2421
+ )
2422
+ for (const match of source.matchAll(direct)) {
2423
+ names[match[1]!] = match[2]!
2424
+ }
2425
+
2426
+ const binding = new RegExp(
2427
+ `(?:const|let|var)\\s+([A-Za-z_$][\\w$]*)\\s*=\\s*require\\(\\s*["']${escaped}["']\\s*\\)`,
2428
+ ).exec(source)?.[1]
2429
+ if (binding) {
2430
+ const local = escapeRegex(binding)
2431
+ const assignment = new RegExp(
2432
+ `exports\\.([A-Za-z_$][\\w$]*)\\s*=\\s*${local}\\.([A-Za-z_$][\\w$]*)`,
2433
+ 'g',
2434
+ )
2435
+ for (const match of source.matchAll(assignment)) {
2436
+ names[match[1]!] = match[2]!
2437
+ }
2438
+ const getter = new RegExp(
2439
+ `Object\\.defineProperty\\(\\s*exports\\s*,\\s*["']([A-Za-z_$][\\w$]*)["'](?:(?!Object\\.defineProperty)[\\s\\S]){0,300}?return\\s+${local}\\.([A-Za-z_$][\\w$]*)`,
2440
+ 'g',
2441
+ )
2442
+ for (const match of source.matchAll(getter)) {
2443
+ names[match[1]!] = match[2]!
2444
+ }
2445
+ const helper = new RegExp(
2446
+ `(?:^|[,\\{])\\s*([A-Za-z_$][\\w$]*)\\s*:\\s*\\(\\s*\\)\\s*=>\\s*${local}\\.([A-Za-z_$][\\w$]*)`,
2447
+ 'g',
2448
+ )
2449
+ for (const match of source.matchAll(helper)) {
2450
+ names[match[1]!] = match[2]!
2451
+ }
2452
+ }
2453
+
2454
+ const star = new RegExp(
2455
+ `module\\.exports\\s*=\\s*(?:require\\(\\s*["']${escaped}["']\\s*\\)|${binding ?? '(?!)'})(?=\\s*(?:;|$))`,
2456
+ 'm',
2457
+ ).test(source)
2458
+ return { names, star }
2459
+ }
2460
+
2461
+ function commonJsRouteExportNames(source: string) {
2462
+ return [
2463
+ ...new Set([
2464
+ ...[...source.matchAll(/\bexports\.([A-Za-z_$][\w$]*)\s*=/g)].map(match => match[1]!),
2465
+ ...[
2466
+ ...source.matchAll(
2467
+ /\bObject\.defineProperty\(\s*exports\s*,\s*["']([A-Za-z_$][\w$]*)["']/g,
2468
+ ),
2469
+ ].map(match => match[1]!),
2470
+ ]),
2471
+ ].filter(name => name !== '__esModule')
2472
+ }
2473
+
2255
2474
  function assertSupportedDynamicCalls(file: string, source: string, dynamicNames: Set<string>) {
2256
2475
  for (const call of dynamicCallsFromSource(source, dynamicNames)) {
2257
2476
  if (!/\bimport\s*\(/.test(call.source)) {
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Bun's fetch sends a caller-supplied `host` header verbatim; undici (Node, and therefore Next)
3
+ * always derives Host from the request URL and drops any supplied one. An app that forwards its
4
+ * inbound request headers to an upstream - every proxy/BFF route handler - would otherwise send
5
+ * the local Host upstream, an RFC 9110 7.2 violation that real upstreams reject. Match undici.
6
+ */
7
+
8
+ let installed = false
9
+
10
+ function isHostKey(key: unknown): boolean {
11
+ return typeof key === 'string' && key.length === 4 && key.toLowerCase() === 'host'
12
+ }
13
+
14
+ /** Allocation-free probe: the overwhelmingly common case is no `host` at all. */
15
+ function hasHost(headers: HeadersInit): boolean {
16
+ if (headers instanceof Headers) return headers.has('host')
17
+ if (Array.isArray(headers)) {
18
+ for (const entry of headers) if (isHostKey(entry?.[0])) return true
19
+ return false
20
+ }
21
+ for (const key in headers) if (isHostKey(key)) return true
22
+ return false
23
+ }
24
+
25
+ /** Only ever runs when a `host` was actually supplied, so the copy stays off the hot path. */
26
+ function withoutHost(headers: HeadersInit): Headers {
27
+ const next = new Headers(headers)
28
+ next.delete('host')
29
+ return next
30
+ }
31
+
32
+ /** Idempotent; safe to call from every server and build entry point. */
33
+ export function installFetchHostNormalization(): void {
34
+ if (installed) return
35
+ installed = true
36
+ const original = globalThis.fetch.bind(globalThis)
37
+ globalThis.fetch = function fetch(input: RequestInfo | URL, init?: RequestInit) {
38
+ const source = init?.headers ?? (input instanceof Request ? input.headers : undefined)
39
+ if (source === undefined || !hasHost(source)) return original(input, init)
40
+ return original(input, { ...init, headers: withoutHost(source) })
41
+ } as typeof globalThis.fetch
42
+ }
@@ -0,0 +1,132 @@
1
+ // pnext's own sources shape every artifact it emits, so a framework edit must invalidate an app's
2
+ // compiled caches exactly the way an app edit does. ONE fingerprint over pnext's whole shipped `src`
3
+ // tree does that - never a hand-picked directory list, which is how a compat-shim edit came to leave
4
+ // a stale vendor bundle serving a bug that had already been fixed.
5
+ import { createHash } from 'node:crypto'
6
+ import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, type Dirent } from 'node:fs'
7
+ import path from 'node:path'
8
+ import { fileURLToPath } from 'node:url'
9
+
10
+ /**
11
+ * Exactly what the package publishes and the vercel adapter ships: source extensions only, never
12
+ * declarations (both drop them) and never machine-local files like `.DS_Store`. The same tree must
13
+ * fingerprint identically in a workspace, in an npm install and inside a deployed function, or the
14
+ * function distrusts the cache its own build wrote and re-vendors onto a read-only filesystem.
15
+ */
16
+ const SOURCE_EXTENSIONS = new Set(['.ts', '.tsx', '.js', '.jsx', '.cjs', '.mjs'])
17
+ const DECLARATION = /\.d\.[cm]?ts$/
18
+
19
+ const frameworkSrc = fileURLToPath(new URL('..', import.meta.url))
20
+
21
+ /** Every shipped source under `dir`, sorted, so the fold order is stable. */
22
+ function sourceFiles(dir: string, found: string[] = []) {
23
+ let entries: Dirent[]
24
+ try {
25
+ entries = readdirSync(dir, { withFileTypes: true })
26
+ } catch {
27
+ return found
28
+ }
29
+ for (const entry of entries.sort((a, b) => (a.name < b.name ? -1 : 1))) {
30
+ const full = path.join(dir, entry.name)
31
+ if (entry.isDirectory()) sourceFiles(full, found)
32
+ else if (SOURCE_EXTENSIONS.has(path.extname(entry.name)) && !DECLARATION.test(entry.name))
33
+ found.push(full)
34
+ }
35
+ return found
36
+ }
37
+
38
+ function contentFingerprint(files: string[], dir: string) {
39
+ const hash = createHash('sha256')
40
+ for (const file of files) {
41
+ hash.update(path.relative(dir, file))
42
+ try {
43
+ hash.update(Bun.hash(readFileSync(file)).toString(36))
44
+ } catch {
45
+ // A file that vanished mid-walk simply contributes its name.
46
+ }
47
+ hash.update('\0')
48
+ }
49
+ return hash.digest('hex').slice(0, 16)
50
+ }
51
+
52
+ /** Content identity of a framework source tree. Pure — no memo, no record. */
53
+ export function computeFrameworkFingerprint(dir = frameworkSrc) {
54
+ return contentFingerprint(sourceFiles(dir), dir)
55
+ }
56
+
57
+ // Reading 4.4 MB of source costs ~13 ms; one stat per file costs ~3 ms. So the same three-step
58
+ // ladder the module index and the client key use: the record maps a stat signature to the content
59
+ // fingerprint it produced, a matching signature reuses it, and anything else re-reads. A touch that
60
+ // changed nothing (a reinstall, a checkout, a staged copy) re-reads once and records the same
61
+ // fingerprint, so it never costs a rebuild - only fingerprinting mtimes directly would.
62
+ const RECORD_VERSION = 1
63
+
64
+ interface Record {
65
+ version: number
66
+ stats: string
67
+ fingerprint: string
68
+ }
69
+
70
+ function statSignature(files: string[], dir: string) {
71
+ const hash = createHash('sha256')
72
+ for (const file of files) {
73
+ const stats = statSync(file, { throwIfNoEntry: false })
74
+ hash.update(`${path.relative(dir, file)}:${stats?.size ?? -1}:${stats?.mtimeMs ?? -1}\0`)
75
+ }
76
+ return hash.digest('hex').slice(0, 16)
77
+ }
78
+
79
+ function recordFile(dir: string) {
80
+ return path.join(dir, 'framework.json')
81
+ }
82
+
83
+ function readRecord(dir: string) {
84
+ try {
85
+ const parsed = JSON.parse(readFileSync(recordFile(dir), 'utf8')) as Record
86
+ return parsed.version === RECORD_VERSION && parsed.stats && parsed.fingerprint
87
+ ? parsed
88
+ : undefined
89
+ } catch {
90
+ return undefined
91
+ }
92
+ }
93
+
94
+ function writeRecord(dir: string, record: Record) {
95
+ try {
96
+ mkdirSync(dir, { recursive: true })
97
+ writeFileSync(recordFile(dir), JSON.stringify(record))
98
+ } catch {
99
+ // Non-fatal: an unwritable record only costs the next boot a re-read.
100
+ }
101
+ }
102
+
103
+ let fingerprint: string | undefined
104
+ const recorded = new Set<string>()
105
+
106
+ /**
107
+ * This pnext's generation. Mixed into every compiled-artifact cache key, so an artifact built by one
108
+ * framework generation is never handed to another.
109
+ *
110
+ * `recordDir` (a cache root) buys the O(1) stat path across restarts; without it the tree is read.
111
+ * `PNEXT_FRAMEWORK_FINGERPRINT` pins the value outright — the bisect seam, and how a deployment can
112
+ * replay its build's generation without walking the tree at all.
113
+ */
114
+ export function frameworkFingerprint(recordDir?: string) {
115
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
116
+ const pinned = process.env.PNEXT_FRAMEWORK_FINGERPRINT
117
+ if (pinned) return pinned
118
+ // Memoized, but a second app in the same process still gets its own record - without one its next
119
+ // boot pays the full read for a fingerprint this process already knows.
120
+ if (fingerprint !== undefined && (!recordDir || recorded.has(recordDir))) return fingerprint
121
+ const files = sourceFiles(frameworkSrc)
122
+ const stats = statSignature(files, frameworkSrc)
123
+ const previous = recordDir ? readRecord(recordDir) : undefined
124
+ fingerprint =
125
+ previous?.stats === stats ? previous.fingerprint : contentFingerprint(files, frameworkSrc)
126
+ if (recordDir) {
127
+ recorded.add(recordDir)
128
+ if (previous?.stats !== stats)
129
+ writeRecord(recordDir, { version: RECORD_VERSION, stats, fingerprint })
130
+ }
131
+ return fingerprint
132
+ }
@@ -40,6 +40,7 @@ import {
40
40
  sourceHasDynamicImport,
41
41
  } from '../resolve/dynamic'
42
42
  import { cacheRoot } from './module-cache'
43
+ import { frameworkFingerprint } from './fingerprint'
43
44
  import { readNodeModuleBundle, writeNodeModuleBundle } from '../dev/restart/node-modules'
44
45
  import { setBasePathPrefix, setDefaultPrefetchMode, setTrailingSlashUrls } from '../routing/href'
45
46
  import { resolveExternalLoadTarget, resolveImport, workspacePackageRoots } from '../resolve/imports'
@@ -481,15 +482,16 @@ const materializedSegment = `${path.sep}cache${path.sep}server${path.sep}`
481
482
  * Where a transform of `file` is remembered across restarts. Only for the
482
483
  * materialized `cache/server` modules: they are hash-named build output under
483
484
  * our own cache root, so the key (path + mtime + size + the define/extension
484
- * inputs of the transform) is complete, and the whole tree is discarded when
485
- * a build or a config change rewrites it.
485
+ * inputs of the transform, and pnext's generation, which shapes the rewrite) is
486
+ * complete, and the whole tree is discarded when a build or a config change
487
+ * rewrites it.
486
488
  */
487
- function transformCacheFile(file: string, fileStat: { mtimeMs: number; size: number }) {
489
+ export function transformCacheFile(file: string, fileStat: { mtimeMs: number; size: number }) {
488
490
  if (transformDiskCacheDisabled) return undefined
489
491
  const at = file.lastIndexOf(materializedSegment)
490
492
  if (at === -1) return undefined
491
493
  const cacheRoot = file.slice(0, at + materializedSegment.length)
492
- const key = `${file}\0${fileStat.mtimeMs}\0${fileStat.size}\0${currentLoadExtras().join(',')}\0${JSON.stringify(serverDefineOptions())}`
494
+ const key = `${file}\0${fileStat.mtimeMs}\0${fileStat.size}\0${currentLoadExtras().join(',')}\0${JSON.stringify(serverDefineOptions())}\0${frameworkFingerprint()}`
493
495
  return path.join(cacheRoot, 'transform', `${hashBundleSpecifier(key)}.js`)
494
496
  }
495
497
 
@@ -628,7 +630,7 @@ function nodeModuleBundleRecordFile(file: string, fileStat: { mtimeMs: number; s
628
630
  if (transformDiskCacheDisabled) return undefined
629
631
  const outPath = (runtimeConfigForFile(file) ?? firstRuntimeConfig())?.resolved.outPath
630
632
  if (!outPath) return undefined
631
- const key = `${file}\0${fileStat.mtimeMs}\0${fileStat.size}\0${currentLoadExtras().join(',')}\0${JSON.stringify(serverDefineOptions())}`
633
+ const key = `${file}\0${fileStat.mtimeMs}\0${fileStat.size}\0${currentLoadExtras().join(',')}\0${JSON.stringify(serverDefineOptions())}\0${frameworkFingerprint()}`
632
634
  return path.join(cacheRoot(outPath), 'node-module-bundle', `${hashBundleSpecifier(key)}.json`)
633
635
  }
634
636
 
@@ -7,8 +7,9 @@
7
7
  //
8
8
  // The graph the hash walks is read from a persisted index keyed by (mtime, size, content hash), so a
9
9
  // restart re-derives it with one stat per source instead of re-scanning and re-resolving every import.
10
- import { mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from 'node:fs'
10
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
11
11
  import { cachedExistsSync } from '../utils/fs-cache'
12
+ import { frameworkFingerprint } from './fingerprint'
12
13
  import { mkdir, readFile, stat } from 'node:fs/promises'
13
14
  import { createHash, randomUUID } from 'node:crypto'
14
15
  import path from 'node:path'
@@ -220,7 +221,7 @@ function createDevModuleCache(
220
221
  const generated = (file: string) =>
221
222
  file.startsWith(outRoot) || file.includes(`${path.sep}pnext-pages-compat${path.sep}`)
222
223
  const key = createHash('sha256')
223
- .update(`${options.compileKey}\0${frameworkFingerprint()}`)
224
+ .update(`${options.compileKey}\0${frameworkFingerprint(root)}`)
224
225
  .digest('hex')
225
226
  .slice(0, 16)
226
227
  establishCacheTrust(root)
@@ -747,37 +748,3 @@ function serializeIndex(key: string, records: Map<string, SourceRecord>, workspa
747
748
  }
748
749
  return JSON.stringify({ version: INDEX_VERSION, key, files } satisfies IndexFile)
749
750
  }
750
-
751
- // pnext's own compiler sources shape every artifact, so editing the framework must invalidate the cache the
752
- // same way editing app code does - but ONLY an edit. Fingerprinting mtimes threw the whole cache away
753
- // whenever a file was merely touched (a reinstall, a checkout, a staged copy, a no-op save), so this reads
754
- // the files that actually emit compiled output. A few dozen small files, once per process, off the boot
755
- // path - the first compile pays it.
756
- let fingerprint: string | undefined
757
-
758
- function frameworkFingerprint() {
759
- if (fingerprint !== undefined) return fingerprint
760
- const hash = createHash('sha256')
761
- for (const dir of ['.', '..', '../runtime', '../compat/bundler', '../resolve']) {
762
- const root = new URL(`${dir}/`, import.meta.url).pathname
763
- let entries: string[]
764
- try {
765
- entries = readdirSync(root).sort()
766
- } catch {
767
- continue
768
- }
769
- for (const entry of entries) {
770
- // Declarations emit nothing, and a deployed build ships without them
771
- // (the vercel adapter drops `.d.ts`) — hashing one would key the cache to
772
- // a file that is provably absent where the artifacts are replayed.
773
- if (!entry.endsWith('.ts') || /\.d\.[cm]?ts$/.test(entry)) continue
774
- try {
775
- hash.update(`${entry}:${Bun.hash(readFileSync(path.join(root, entry))).toString(36)}\0`)
776
- } catch {
777
- // ignore unreadable entries
778
- }
779
- }
780
- }
781
- fingerprint = hash.digest('hex').slice(0, 16)
782
- return fingerprint
783
- }