@wular/pnext 0.0.9 → 0.0.11

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 (60) hide show
  1. package/package.json +1 -1
  2. package/reference/dev.md +17 -4
  3. package/src/api/dynamic.tsx +1 -0
  4. package/src/api/link.tsx +1 -0
  5. package/src/cli/analyze.ts +1 -1
  6. package/src/cli/boot/trace.ts +40 -7
  7. package/src/cli/build.ts +2 -2
  8. package/src/cli/dev.ts +5 -10
  9. package/src/cli/migrate/package-json.ts +1 -1
  10. package/src/cli/migrate/report.ts +5 -3
  11. package/src/cli/migrate/run.ts +30 -4
  12. package/src/cli/migrate/scan.ts +34 -7
  13. package/src/cli/typegen.ts +8 -1
  14. package/src/client/build.ts +4 -5
  15. package/src/client/profile.ts +4 -3
  16. package/src/client/router/runtime.ts +8 -0
  17. package/src/compat/actions/config.ts +2 -0
  18. package/src/compat/aliases.ts +2 -2
  19. package/src/compat/bundler/react-profiler.tsx +1 -0
  20. package/src/compat/cache-control.ts +49 -119
  21. package/src/compat/client/errors/global-error.ts +10 -5
  22. package/src/compat/client/errors/install.ts +30 -6
  23. package/src/compat/client/errors/lazy.ts +32 -2
  24. package/src/compat/next/client-script.tsx +1 -0
  25. package/src/compat/next/config-headers.ts +139 -0
  26. package/src/compat/next/config-loader.ts +32 -2
  27. package/src/compat/next/dynamic.tsx +1 -0
  28. package/src/compat/next/error.tsx +1 -0
  29. package/src/compat/next/form.tsx +1 -0
  30. package/src/compat/next/head.tsx +1 -0
  31. package/src/compat/next/headers.ts +12 -6
  32. package/src/compat/next/image/client.tsx +1 -0
  33. package/src/compat/next/image.tsx +1 -0
  34. package/src/compat/next/legacy-image.tsx +1 -0
  35. package/src/compat/next/legacy-request-apis.ts +62 -0
  36. package/src/compat/next/link.tsx +1 -0
  37. package/src/compat/next/rewrites.ts +7 -0
  38. package/src/compat/next/script.tsx +1 -0
  39. package/src/compat/register/bundler.ts +6 -1
  40. package/src/compat/segment/loading-boundary.ts +1 -5
  41. package/src/compat/validation/validate.ts +10 -2
  42. package/src/config.ts +12 -1
  43. package/src/css/build.ts +111 -4
  44. package/src/dev/internal-error.ts +128 -0
  45. package/src/dev/restart/global-css.ts +3 -3
  46. package/src/dev/restart/route-facts.ts +3 -3
  47. package/src/dev/server.ts +2 -2
  48. package/src/render/renderer.ts +20 -2
  49. package/src/render/slots.tsx +1 -0
  50. package/src/request/context.ts +15 -0
  51. package/src/runtime/loader.ts +2 -2
  52. package/src/runtime/module-cache.ts +12 -5
  53. package/src/runtime/modules.ts +44 -31
  54. package/src/runtime/vendor-build.ts +5 -7
  55. package/src/runtime/vendor.ts +29 -13
  56. package/src/types.ts +10 -1
  57. package/src/utils/code.ts +5 -0
  58. package/src/utils/dev-profile.ts +1 -1
  59. package/src/utils/esbuild.ts +37 -2
  60. package/src/utils/trace-flags.ts +66 -0
@@ -106,6 +106,7 @@ import {
106
106
  import { captureCacheScope, runWithCacheScope } from '../request/cache'
107
107
  import { registerServerRuntime } from '../runtime/loader'
108
108
  import { readText } from '../utils/fs'
109
+ import { traceEnabled } from '../utils/trace-flags'
109
110
  import {
110
111
  flushDevProfileLines,
111
112
  formatProfileDuration,
@@ -168,6 +169,7 @@ import {
168
169
  type RouteParamsSnapshot,
169
170
  } from './island-context'
170
171
  import { escapeHtml } from '../utils/html'
172
+ import { internalErrorHtml, isPnextInternalError } from '../dev/internal-error'
171
173
  import type {
172
174
  Metadata,
173
175
  MetadataLink,
@@ -1094,8 +1096,7 @@ interface ProfileRenderOptions {
1094
1096
  }
1095
1097
 
1096
1098
  function renderProfile(options: ProfileRenderOptions): RenderProfile | undefined {
1097
- // eslint-disable-next-line turbo/no-undeclared-env-vars
1098
- if (!process.env.PNEXT_DEV_PROFILE || !options.dev) return undefined
1099
+ if (!traceEnabled('server') || !options.dev) return undefined
1099
1100
  return { label: options.route.route }
1100
1101
  }
1101
1102
 
@@ -2992,6 +2993,23 @@ async function pageRender(options: RenderOptions, stream: boolean): Promise<Page
2992
2993
  },
2993
2994
  )
2994
2995
  }
2996
+ // An error thrown entirely inside pnext's own frames is a framework bug the app cannot fix:
2997
+ // in dev it gets the loud internal-error document (full trace + prefilled GitHub report)
2998
+ // instead of hiding behind the app's error boundaries or the generic global-error page.
2999
+ if (options.dev) {
3000
+ const resolved = resolveThrownError(error, nextCompatEnabled(options.config))
3001
+ if (isPnextInternalError(resolved)) {
3002
+ logDevError(options, resolved)
3003
+ return globalErrorPage(
3004
+ internalErrorHtml({
3005
+ error: resolved,
3006
+ route: options.route.route,
3007
+ url: String(options.url),
3008
+ digest: (resolved as Error & { digest?: string }).digest,
3009
+ }),
3010
+ )
3011
+ }
3012
+ }
2995
3013
  // A nearest error.* boundary catches first (rendered inside the document).
2996
3014
  // Only when no error boundary exists does global-error.* take over the whole
2997
3015
  // document (it supplies its own <html>/<body>); otherwise the normal error
@@ -1,3 +1,4 @@
1
+ /** @jsxImportSource preact */
1
2
  import { AsyncLocalStorage } from 'node:async_hooks'
2
3
  import { basename, join, relative, sep } from 'node:path'
3
4
  import { h, type ComponentChildren, type ComponentType } from 'preact'
@@ -346,6 +346,21 @@ function dynamicBailoutRoute(): string | undefined {
346
346
  return typeof route === 'string' ? route : undefined
347
347
  }
348
348
 
349
+ /**
350
+ * compat.next.legacyRequestAPIs — Next 15's transitional sync request APIs. Core owns the boolean cell
351
+ * (set from loadConfig); compat's cookies()/headers()/draftMode() read it. globalThis-anchored so a
352
+ * prebundled server entry carrying its own copy of this module sees the same flag.
353
+ */
354
+ const LEGACY_REQUEST_APIS = Symbol.for('pnext.legacyRequestAPIs')
355
+
356
+ export function setLegacyRequestAPIs(enabled: boolean): void {
357
+ ;(globalThis as Record<PropertyKey, unknown>)[LEGACY_REQUEST_APIS] = enabled
358
+ }
359
+
360
+ export function legacyRequestAPIs(): boolean {
361
+ return (globalThis as Record<PropertyKey, unknown>)[LEGACY_REQUEST_APIS] === true
362
+ }
363
+
349
364
  export function requireRequest(api: string) {
350
365
  const probeRoute = dynamicBailoutRoute()
351
366
  if (probeRoute !== undefined) {
@@ -44,6 +44,7 @@ import { readNodeModuleBundle, writeNodeModuleBundle } from '../dev/restart/node
44
44
  import { setBasePathPrefix, setDefaultPrefetchMode, setTrailingSlashUrls } from '../routing/href'
45
45
  import { resolveExternalLoadTarget, resolveImport, workspacePackageRoots } from '../resolve/imports'
46
46
  import { escapeRegex } from '../utils/code'
47
+ import { traceEnabled } from '../utils/trace-flags'
47
48
  import { writeFileAtomic } from '../utils/fs'
48
49
 
49
50
  const require = createRequire(import.meta.url)
@@ -302,8 +303,7 @@ function registerResolvePlugin() {
302
303
  function registerLoadPlugin(root: string) {
303
304
  if (registeredLoadRoots.has(root)) return
304
305
  registeredLoadRoots.add(root)
305
- // eslint-disable-next-line turbo/no-undeclared-env-vars
306
- if (process.env.PNEXT_DEBUG_LOADPLUGIN) {
306
+ if (traceEnabled('server')) {
307
307
  console.error(`[loadplugin] register root=${root} extras=${currentLoadExtras().join(',')}`)
308
308
  }
309
309
  loadPluginExtras.set(root, currentLoadExtras())
@@ -15,6 +15,7 @@ import path from 'node:path'
15
15
  import { fileURLToPath } from 'node:url'
16
16
  import type { ResolvedConfig } from '../config'
17
17
  import { writeFileAtomic } from '../utils/fs'
18
+ import { traceEnabled } from '../utils/trace-flags'
18
19
 
19
20
  /**
20
21
  * Kill switch for the request-head trims: the naming walk's synchronous source
@@ -224,12 +225,11 @@ function createDevModuleCache(
224
225
  .slice(0, 16)
225
226
  establishCacheTrust(root)
226
227
  const records = indexRecords(readIndexFile(indexFile), key, workspaceRoot)
227
- // PNEXT_DEV_CACHE_DEBUG=1 names every source whose hash differs from the one
228
+ // PNEXT_TRACE=server names every source whose hash differs from the one
228
229
  // the last boot persisted — i.e. exactly why an artifact was renamed and
229
230
  // recompiled instead of reused.
230
231
  const booted =
231
- // eslint-disable-next-line turbo/no-undeclared-env-vars
232
- process.env.PNEXT_DEV_CACHE_DEBUG && records.size > 0
232
+ traceEnabled('server') && records.size > 0
233
233
  ? new Map([...records].map(([file, record]) => [file, record.srcHash] as const))
234
234
  : undefined
235
235
  const pending = new Map<string, Promise<SourceRecord>>()
@@ -613,8 +613,15 @@ function markerFile(root: string) {
613
613
  */
614
614
  function establishCacheTrust(root: string) {
615
615
  const current = readMarker(root)
616
- const value = current ?? randomUUID()
617
- if (!current) {
616
+ // Generation-qualified: artifacts embed paths shaped by pnext's own compile/vendor source (vendor
617
+ // keys, emitted import shapes), so a cache written by a different pnext generation must not be
618
+ // reused - 0.0.9-era modules kept importing vendor artifacts 0.0.10 keyed differently, and every
619
+ // request through one 500'd until a manual wipe. A mismatched or legacy marker distrusts the
620
+ // folder; everything recompiles in place.
621
+ const generation = `${frameworkFingerprint()}:`
622
+ const trusted = current?.startsWith(generation) ? current : undefined
623
+ const value = trusted ?? `${generation}${randomUUID()}`
624
+ if (!trusted) {
618
625
  writeMarker(root, value)
619
626
  wiped.add(root)
620
627
  }
@@ -23,6 +23,7 @@ import {
23
23
  } from './loader'
24
24
  import { externalServerPackageHref, runtimeServerImportTarget } from '../runtime/vendor-build'
25
25
  import { writeFileAtomic } from '../utils/fs'
26
+ import { traceEnabled, traceValue } from '../utils/trace-flags'
26
27
  import { createHash } from 'node:crypto'
27
28
  import {
28
29
  extraLoadableExtensions,
@@ -39,12 +40,13 @@ import {
39
40
  type CompatAliasTarget,
40
41
  type ResolvedConfig,
41
42
  } from '../config'
42
- import { readText, toPosixPath } from '../utils/fs'
43
+ import { readText, toPosixPath, writeText } from '../utils/fs'
43
44
  import {
44
45
  type ExternalLoadTarget,
45
46
  externalPackageImportTarget,
46
47
  getExternalPackagePolicy,
47
48
  isCommonJsModuleSource,
49
+ isEsmModuleFile,
48
50
  packageNameOfSpecifier,
49
51
  resolveImport,
50
52
  resolveExternalLoadTarget,
@@ -99,7 +101,7 @@ const buildQueue: (() => void)[] = []
99
101
  const moduleBuilds = new Map<string, Promise<string>>()
100
102
  const routeBundleBuilds = new Map<string, Promise<string>>()
101
103
 
102
- // PNEXT_DEV_MODULE_STATS=1: attribute every per-module compile to its profile,
104
+ // PNEXT_TRACE=server: attribute every per-module compile to its profile,
103
105
  // so cross-profile duplicate work is countable rather than inferred. Counts are
104
106
  // load-insensitive, which is what makes them usable on a contended box.
105
107
  interface ModuleStat {
@@ -109,9 +111,9 @@ interface ModuleStat {
109
111
  }
110
112
  // `${profile}:${file}` of modules that fell off the oxc fast path onto esbuild.
111
113
  const esbuildFallbacks = new Set<string>()
112
- const moduleStats: Map<string, ModuleStat[]> | undefined =
113
- // eslint-disable-next-line turbo/no-undeclared-env-vars
114
- process.env.PNEXT_DEV_MODULE_STATS === '1' ? new Map() : undefined
114
+ const moduleStats: Map<string, ModuleStat[]> | undefined = traceEnabled('server')
115
+ ? new Map()
116
+ : undefined
115
117
  let moduleStatsFlush: Timer | undefined
116
118
 
117
119
  function noteModuleCompile(file: string, profile: string, ms: number, esbuild: boolean) {
@@ -163,10 +165,11 @@ function reportModuleStats() {
163
165
  `${duplicateCompiles} redundant compiles, ${duplicateMs.toFixed(0)} ms nested-wall`,
164
166
  ].join('\n'),
165
167
  )
166
- // PNEXT_DEV_MODULE_STATS_OUT=<path>: also dump the per-file list, so a floor
167
- // bench can replay the exact module set a route compiles in each profile.
168
- // eslint-disable-next-line turbo/no-undeclared-env-vars
169
- const out = process.env.PNEXT_DEV_MODULE_STATS_OUT
168
+ // PNEXT_TRACE=server=<path>: also dump the per-file list, so a floor bench can
169
+ // replay the exact module set a route compiles in each profile. A `.jsonl` path
170
+ // belongs to the heavy-package profile instead (see vendor.ts).
171
+ const out = traceValue('server')
172
+ if (out?.endsWith('.jsonl')) return
170
173
  if (!out) return
171
174
  const files = [...moduleStats.entries()].flatMap(([file, entries]) =>
172
175
  entries.map(entry => ({ file, profile: entry.profile, ms: entry.ms, esbuild: entry.esbuild })),
@@ -571,8 +574,7 @@ function startRouteVendorStage(config: ResolvedConfig, route: RouteManifestEntry
571
574
  }
572
575
  }
573
576
  for (const specifier of demand) dropped.delete(specifier)
574
- // eslint-disable-next-line turbo/no-undeclared-env-vars
575
- if (process.env.PNEXT_DEV_PROFILE) {
577
+ if (traceEnabled('server')) {
576
578
  console.log(
577
579
  `dev-import vendor seed walk ${route.route} in ${formatDuration(performance.now() - seedT0)} (kept ${demand.size}, dropped ${dropped.size})`,
578
580
  )
@@ -1873,26 +1875,21 @@ async function resolveDevModuleSpecifier(
1873
1875
  )
1874
1876
  return { path: `${pathToFileHref(stub)}${hash}`, external: true }
1875
1877
  }
1876
- // The compiled-path rewrite below is only safe for imports the WRITE pass walks:
1877
- // writeDevModuleUncached compiles the entry source's own local import targets, nothing deeper. A
1878
- // first-party compat runtime file pulled in by an alias that esbuild BUNDLES contributes its own
1879
- // relative imports through this hook, and nothing ever writes those into the profile cache - the
1880
- // import would resolve to a file that does not exist. Point such a transitive import at its own
1881
- // source instead; Bun compiles the `.ts` on import.
1878
+ // A `require()` of an ES module cannot take the compiled-path rewrite: the artifact is ESM, and
1879
+ // requiring one throws the moment it is not synchronously evaluable. Let esbuild bundle it and do
1880
+ // the interop, exactly as the alias branch above already does for a require-call. Under an
1881
+ // npm-layout install this is the common case - node_modules is INSIDE workspaceRoot, so a
1882
+ // package's own ESM file counts as first-party source here.
1883
+ if (kind === 'require-call' && isEsmModuleFile(resolved)) return { path: resolved }
1882
1884
  const importerFile = importer && !importer.startsWith('<') ? path.resolve(importer) : ''
1885
+ // `importerFile !== file` keeps this to imports the write pass misses: the entry's OWN local
1886
+ // imports are already walked by writeDevModuleUncached, so another importer means esbuild INLINED
1887
+ // that module here and nobody compiles its relative siblings. It can never also key on the importer
1888
+ // being out-of-workspace - under an npm-layout install pnext's runtime files live in the app's
1889
+ // node_modules, and the sibling was then silently never written. Recompiling is not a risk:
1890
+ // writeDevModule returns an already-written output as-is, so island identity survives navigation.
1883
1891
  const bundledCompatImporter =
1884
- isLocalSourceSpecifier &&
1885
- importerFile !== '' &&
1886
- importerFile !== path.resolve(file) &&
1887
- !isInside(config.workspaceRoot, importerFile) &&
1888
- !isInside(config.workspaceRoot, resolved)
1889
- // The `importerFile !== file` term above keeps this to imports the write pass misses: the entry's
1890
- // OWN local imports are already walked by writeDevModuleUncached. Everything deeper - a relative
1891
- // sibling of a compat file esbuild inlined into the entry - is nobody's job but this one, whether
1892
- // the entry is a workspace module or a node_modules-nested 'use client' component. Gating on an
1893
- // in-workspace entry left those nested client references importing a file that was never written.
1894
- // Recompiling is not a risk: writeDevModule returns an already-written output as-is, so island
1895
- // identity across a soft navigation is preserved.
1892
+ isLocalSourceSpecifier && importerFile !== '' && importerFile !== path.resolve(file)
1896
1893
  if (bundledCompatImporter) {
1897
1894
  // Compile it here, keeping the same compiled-path rewrite so the module stays a single instance -
1898
1895
  // pointing at the raw source instead would load a SECOND copy of the compat runtime graph and split its
@@ -2422,6 +2419,14 @@ async function linkDirectoryAssetContext(
2422
2419
  }
2423
2420
  if (existsSync(target)) return
2424
2421
  await mkdir(path.dirname(target), { recursive: true })
2422
+ // A package manifest linked whole makes the staged mirror a RESOLVABLE package: a bare
2423
+ // `pkg/sub` import from a compiled artifact finds this copy first and fails on an `exports`
2424
+ // subpath that only exists in the real tree. Stage the one field the mirror needs (`type`,
2425
+ // which decides how a compiled `.js` sibling parses) and none of the resolution map.
2426
+ if (entry.name === 'package.json') {
2427
+ await writeText(target, await stagedManifestSource(source))
2428
+ return
2429
+ }
2425
2430
  try {
2426
2431
  await symlink(source, target, 'file')
2427
2432
  } catch {
@@ -2431,6 +2436,15 @@ async function linkDirectoryAssetContext(
2431
2436
  )
2432
2437
  }
2433
2438
 
2439
+ async function stagedManifestSource(file: string) {
2440
+ try {
2441
+ const manifest = JSON.parse(await readText(file)) as { type?: unknown }
2442
+ return JSON.stringify(typeof manifest.type === 'string' ? { type: manifest.type } : {})
2443
+ } catch {
2444
+ return '{}'
2445
+ }
2446
+ }
2447
+
2434
2448
  // Recursively stage an asset directory: link non-code leaf files and recurse
2435
2449
  // into subdirectories, but never link code files — those compile through the
2436
2450
  // module graph into their own staged output and must not be shadowed by a raw
@@ -2535,8 +2549,7 @@ function uniqueFiles(files: string[]) {
2535
2549
  }
2536
2550
 
2537
2551
  async function profileDevImport<T>(label: string, task: () => Promise<T>) {
2538
- // eslint-disable-next-line turbo/no-undeclared-env-vars
2539
- if (!process.env.PNEXT_DEV_PROFILE) return task()
2552
+ if (!traceEnabled('server')) return task()
2540
2553
  const start = performance.now()
2541
2554
  try {
2542
2555
  return await task()
@@ -41,6 +41,7 @@ import {
41
41
  } from '../resolve/imports'
42
42
  import { clearNodeModulesResolutionCache } from '../resolve/engine'
43
43
  import { cachedExistsSync, clearNodeModulesFsCache } from '../utils/fs-cache'
44
+ import { traceEnabled } from '../utils/trace-flags'
44
45
  import {
45
46
  clientReferenceExportNames,
46
47
  clientReferenceModuleSource,
@@ -729,8 +730,7 @@ export function vendorPreplanReport() {
729
730
  return { ...vendorPreplanStats, reasons: vendorPreplanStats.reasons.slice(0, 60) }
730
731
  }
731
732
 
732
- // eslint-disable-next-line turbo/no-undeclared-env-vars
733
- const vendorPreplanDebug = () => process.env.PNEXT_VENDOR_PREPLAN_DEBUG === '1'
733
+ const vendorPreplanDebug = () => traceEnabled('vendor')
734
734
  let preplanReportFlush: ReturnType<typeof setTimeout> | undefined
735
735
  function schedulePreplanReport() {
736
736
  if (!vendorPreplanDebug()) return
@@ -1130,12 +1130,11 @@ async function buildVendorGroup(
1130
1130
  await emitNestedExternalTraceCopies(built[0]!.file, context.nestedExternalFiles)
1131
1131
  }
1132
1132
 
1133
- // vendor plugin-callback attribution (PNEXT_VENDOR_PROFILE=1, dormant otherwise). Every
1133
+ // vendor plugin-callback attribution (PNEXT_TRACE=vendor, dormant otherwise). Every
1134
1134
  // onResolve/onLoad is an IPC round trip esbuild's bundler blocks on, against the one JS thread that
1135
1135
  // answers them; counts separate "our callbacks are slow" from "we answer many questions".
1136
1136
 
1137
- // eslint-disable-next-line turbo/no-undeclared-env-vars
1138
- const vendorProfileEnabled = process.env.PNEXT_VENDOR_PROFILE === '1'
1137
+ const vendorProfileEnabled = traceEnabled('vendor')
1139
1138
  const vendorProfileTotals = new Map<string, number>()
1140
1139
 
1141
1140
  function vendorProfileCount(key: string, by = 1) {
@@ -2532,8 +2531,7 @@ function isResolveFailure(error: unknown) {
2532
2531
  }
2533
2532
 
2534
2533
  async function profileRuntimeStep<T>(label: string, task: () => Promise<T>) {
2535
- // eslint-disable-next-line turbo/no-undeclared-env-vars
2536
- if (!process.env.PNEXT_DEV_PROFILE) return task()
2534
+ if (!traceEnabled('server')) return task()
2537
2535
  const start = performance.now()
2538
2536
  try {
2539
2537
  return await task()
@@ -42,6 +42,7 @@ import { isCommonJsModuleSource } from '../resolve/imports'
42
42
  import { outputSpecifiers } from './module-transform'
43
43
  import { escapeRegex, isIdentifier, uniqueIdentifier } from '../utils/code'
44
44
  import { cachedExistsSync } from '../utils/fs-cache'
45
+ import { traceEnabled, traceValue } from '../utils/trace-flags'
45
46
  import { writeFileAtomic } from '../utils/fs'
46
47
 
47
48
  // --------------------------------------------------------------------------
@@ -217,7 +218,7 @@ function promoteVendorWork(work: VendorPromotable | undefined) {
217
218
  }
218
219
 
219
220
  // --------------------------------------------------------------------------
220
- // saturation trace (PNEXT_VENDOR_TRACE=1)
221
+ // saturation trace (PNEXT_TRACE=vendor)
221
222
  // --------------------------------------------------------------------------
222
223
 
223
224
  /** One build's occupancy of a worker slot — the active-builds-over-time input. */
@@ -230,8 +231,7 @@ export interface VendorBuildSpan {
230
231
 
231
232
  const vendorSpans: VendorBuildSpan[] = []
232
233
 
233
- // eslint-disable-next-line turbo/no-undeclared-env-vars
234
- const vendorTracing = () => process.env.PNEXT_VENDOR_TRACE === '1'
234
+ const vendorTracing = () => traceEnabled('vendor')
235
235
 
236
236
  function openVendorSpan(label: string, nested: boolean) {
237
237
  if (!vendorTracing()) return undefined
@@ -245,14 +245,13 @@ function closeVendorSpan(span: VendorBuildSpan | undefined) {
245
245
  }
246
246
 
247
247
  // --------------------------------------------------------------------------
248
- // TEMPORARY JSONL workload trace (PNEXT_VENDOR_TRACE=<path>)
248
+ // TEMPORARY JSONL workload trace (PNEXT_TRACE=vendor=<path>)
249
249
  // --------------------------------------------------------------------------
250
250
 
251
- // A PATH-valued PNEXT_VENDOR_TRACE appends build/request/edge rows for
252
- // bench/tools/vendor-analyze.ts; `=1` still means the in-memory spans only.
251
+ // A path-valued `vendor` scope appends build/request/edge rows for
252
+ // bench/tools/vendor-analyze.ts; a bare `vendor` means the in-memory spans only.
253
253
  const vendorTraceFile = (() => {
254
- // eslint-disable-next-line turbo/no-undeclared-env-vars
255
- const value = process.env.PNEXT_VENDOR_TRACE
254
+ const value = traceValue('vendor')
256
255
  if (!value || !(value.includes('/') || value.endsWith('.jsonl'))) return undefined
257
256
  return path.resolve(value)
258
257
  })()
@@ -881,7 +880,10 @@ const ESM_DIST_ENTRY_MARKER = 'entry'
881
880
 
882
881
  const esmDists = new Map<string, Promise<string | undefined>>()
883
882
 
884
- export function copyBrowserReadyEsmDist(entry: string, distDir: string) {
883
+ export function copyBrowserReadyEsmDist(
884
+ entry: string,
885
+ distDir: string,
886
+ ): Promise<string | undefined> {
885
887
  // One copy per destination: a second copier would rewrite files the first has
886
888
  // already published, and copyFile is not atomic against a concurrent import.
887
889
  let dist = esmDists.get(distDir)
@@ -889,14 +891,24 @@ export function copyBrowserReadyEsmDist(entry: string, distDir: string) {
889
891
  dist = copyBrowserReadyEsmDistUncached(entry, distDir)
890
892
  esmDists.set(distDir, dist)
891
893
  }
892
- return dist
894
+ // A `pnext build` wiping `.pnext` under this server deletes published copies out from under the
895
+ // memo; a bundle repointed at a vanished dist 500s on every import. Re-verify against disk and
896
+ // re-copy - the same only-the-stale-holder-evicts rule dropVendorBundle applies to bundles.
897
+ return dist.then(published => {
898
+ if (published === undefined || existsSync(published)) return published
899
+ if (esmDists.get(distDir) === dist) esmDists.delete(distDir)
900
+ return copyBrowserReadyEsmDist(entry, distDir)
901
+ })
893
902
  }
894
903
 
895
904
  async function copyBrowserReadyEsmDistUncached(entry: string, distDir: string) {
896
905
  const existing = await readFile(path.join(distDir, ESM_DIST_ENTRY_MARKER), 'utf8').catch(
897
906
  () => undefined,
898
907
  )
899
- if (existing !== undefined) return path.join(distDir, existing)
908
+ // The marker without its entry is a half-deleted copy: fall through and rewrite it.
909
+ if (existing !== undefined && existsSync(path.join(distDir, existing))) {
910
+ return path.join(distDir, existing)
911
+ }
900
912
 
901
913
  const closure = await collectBrowserReadyEsmClosure(entry)
902
914
  if (!closure) return undefined
@@ -1050,8 +1062,12 @@ export function cjsNoFacadeMarker(bundleFile: string) {
1050
1062
 
1051
1063
  const facades = new Map<string, Promise<string | undefined>>()
1052
1064
 
1053
- // eslint-disable-next-line turbo/no-undeclared-env-vars
1054
- const heavyProfFile = process.env.PNEXT_HEAVY_PROF
1065
+ // A `.jsonl` value on the server scope selects this profile; any other path is the
1066
+ // per-module stats dump (runtime/modules.ts).
1067
+ const heavyProfFile = (() => {
1068
+ const value = traceValue('server')
1069
+ return value?.endsWith('.jsonl') ? value : undefined
1070
+ })()
1055
1071
  export function heavyProfRow(row: Record<string, unknown>) {
1056
1072
  if (!heavyProfFile) return
1057
1073
  try {
package/src/types.ts CHANGED
@@ -56,11 +56,20 @@ export interface PNextAdapterConfig {
56
56
  }
57
57
 
58
58
  export interface PNextCompatConfig {
59
- next?: boolean
59
+ next?: boolean | PNextNextCompatConfig
60
60
  react?: boolean
61
61
  reactCompiler?: boolean
62
62
  }
63
63
 
64
+ /** Opt-in Next-compat behaviours. Everything here defaults off. */
65
+ export interface PNextNextCompatConfig {
66
+ /**
67
+ * Next 15's transitional sync request APIs: cookies()/headers()/draftMode() return a Promise that
68
+ * also carries the sync surface, warning once per callsite. Off by default (Next 16 semantics).
69
+ */
70
+ legacyRequestAPIs?: boolean
71
+ }
72
+
64
73
  export interface MetadataLink {
65
74
  rel: string
66
75
  url: string
package/src/utils/code.ts CHANGED
@@ -36,3 +36,8 @@ export function uniqueIdentifier(source: string, base: string, ...used: string[]
36
36
  export function isIdentifier(value: string) {
37
37
  return /^[A-Za-z_$][\w$]*$/.test(value)
38
38
  }
39
+
40
+ /** Source with comments removed; the `[^:]` guard keeps `://` inside string URLs intact. */
41
+ export function stripComments(source: string): string {
42
+ return source.replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:])\/\/.*$/gm, '$1')
43
+ }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Buffered sink for PNEXT_DEV_PROFILE lines. A warm request emits many phase lines, and writing each
2
+ * Buffered sink for PNEXT_TRACE=server lines. A warm request emits many phase lines, and writing each
3
3
  * straight to stdout inflated every enclosing span it sat inside - the measurement changed what it
4
4
  * measured. Lines accumulate here and the request handler flushes them in one write.
5
5
  */
@@ -27,10 +27,45 @@ function track<T>(promise: Promise<T>): Promise<T> {
27
27
  })
28
28
  }
29
29
 
30
- export const build: typeof Esbuild.build = options => track(load().build(options))
30
+ // A service killed out from under an in-flight call (memory watchdog, crash) fails it with this
31
+ // exact message; the next call respawns the service, so one retry turns a user-visible 500 into
32
+ // a slightly slower response.
33
+ // "no longer running": issued against a dead cached service. "was stopped": in flight when a
34
+ // stop() (watchdog or the reset below) tore the service down.
35
+ const serviceDied = (error: unknown) =>
36
+ error instanceof Error &&
37
+ (error.message.includes('The service is no longer running') ||
38
+ error.message.includes('The service was stopped'))
39
+
40
+ // esbuild caches the dead service until stop() resets it. The reset is epoch-guarded: every call
41
+ // that raced the same dead service shares one stop(), because a second stop() would kill the fresh
42
+ // child under the retries already running against it.
43
+ let resetEpoch = 0
44
+ let serviceReset: Promise<void> | undefined
45
+
46
+ async function withServiceRetry<T>(run: () => Promise<T>): Promise<T> {
47
+ const epoch = resetEpoch
48
+ try {
49
+ return await track(run())
50
+ } catch (error) {
51
+ if (!serviceDied(error)) throw error
52
+ if (epoch === resetEpoch) {
53
+ serviceReset ??= Promise.resolve(esbuild?.stop())
54
+ .catch(() => undefined)
55
+ .then(() => {
56
+ resetEpoch += 1
57
+ serviceReset = undefined
58
+ })
59
+ await serviceReset
60
+ }
61
+ return track(run())
62
+ }
63
+ }
64
+
65
+ export const build: typeof Esbuild.build = options => withServiceRetry(() => load().build(options))
31
66
 
32
67
  export const transform: typeof Esbuild.transform = (input, options) =>
33
- track(load().transform(input, options))
68
+ withServiceRetry(() => load().transform(input, options))
34
69
 
35
70
  /**
36
71
  * Spawn the resident service child ahead of the first real compile. Fire-and-forget: the spawn and
@@ -0,0 +1,66 @@
1
+ /**
2
+ * `PNEXT_TRACE=<comma-separated scopes>` - the single umbrella for pnext's diagnostic output.
3
+ * The variable is parsed once, on first use; every later check is a map lookup on a boolean test,
4
+ * so a build with tracing off pays nothing.
5
+ *
6
+ * Grammar: entries are `scope` or `scope=value`, comma separated. `1` is an alias for `boot`
7
+ * (`PNEXT_TRACE=1` keeps its old meaning) and `all` turns on every scope.
8
+ * Example: `PNEXT_TRACE=boot,server=/tmp/modules.json`.
9
+ *
10
+ * A scope is all-or-nothing: turning one on turns on everything it covers.
11
+ *
12
+ * | scope | prints / does |
13
+ * | -------- | --------------------------------------------------------------------------------- |
14
+ * | `boot` | boot-phase timings and memory readings |
15
+ * | `server` | dev request / render / build timings, per-module compile attribution, route-facts and global-css hit/miss, cache-invalidation reasons, load-plugin registrations |
16
+ * | `vendor` | vendor saturation spans, esbuild plugin-callback attribution, preplan/native report |
17
+ * | `client` | client-build phase timings, and the esbuild metafile written to the output dir |
18
+ * | `all` | the four above |
19
+ *
20
+ * Two scopes take a `=<path>` value:
21
+ * - `server=<path>` dumps the per-module compile list as JSON; a `.jsonl` path instead collects the
22
+ * heavy-package facade profile (they are never wanted at once, and the suffix picks between them).
23
+ * - `vendor=<path>` appends the JSONL workload trace bench/tools/vendor-analyze.ts reads.
24
+ */
25
+
26
+ /** Parse one raw `PNEXT_TRACE` value into scope -> value (`''` when the entry carried none). */
27
+ export function parseTraceFlags(raw: string | undefined): Map<string, string> {
28
+ const scopes = new Map<string, string>()
29
+ for (const entry of raw?.split(',') ?? []) {
30
+ const trimmed = entry.trim()
31
+ if (!trimmed) continue
32
+ const eq = trimmed.indexOf('=')
33
+ const name = eq === -1 ? trimmed : trimmed.slice(0, eq).trim()
34
+ if (!name) continue
35
+ scopes.set(name === '1' ? 'boot' : name, eq === -1 ? '' : trimmed.slice(eq + 1).trim())
36
+ }
37
+ return scopes
38
+ }
39
+
40
+ let scopes: Map<string, string> | undefined
41
+ let all = false
42
+
43
+ function flags() {
44
+ if (!scopes) {
45
+ // eslint-disable-next-line turbo/no-undeclared-env-vars
46
+ scopes = parseTraceFlags(process.env.PNEXT_TRACE)
47
+ all = scopes.has('all')
48
+ }
49
+ return scopes
50
+ }
51
+
52
+ /** Drop the memoized parse - for tests and benches that flip PNEXT_TRACE in-process. */
53
+ export function resetTraceFlags() {
54
+ scopes = undefined
55
+ all = false
56
+ }
57
+
58
+ /** True when `scope` (or `all`) is listed in PNEXT_TRACE, with or without a value. */
59
+ export function traceEnabled(scope: string): boolean {
60
+ return flags().has(scope) || all
61
+ }
62
+
63
+ /** The `scope=<value>` payload, or undefined when the scope is absent or valueless. */
64
+ export function traceValue(scope: string): string | undefined {
65
+ return flags().get(scope) || undefined
66
+ }