@wular/pnext 0.0.10 → 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 (57) 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/migrate/package-json.ts +1 -1
  9. package/src/cli/migrate/report.ts +5 -3
  10. package/src/cli/migrate/run.ts +30 -4
  11. package/src/cli/migrate/scan.ts +34 -7
  12. package/src/cli/typegen.ts +8 -1
  13. package/src/client/build.ts +4 -5
  14. package/src/client/profile.ts +4 -3
  15. package/src/client/router/runtime.ts +8 -0
  16. package/src/compat/actions/config.ts +2 -0
  17. package/src/compat/aliases.ts +2 -2
  18. package/src/compat/bundler/react-profiler.tsx +1 -0
  19. package/src/compat/cache-control.ts +49 -119
  20. package/src/compat/client/errors/global-error.ts +10 -5
  21. package/src/compat/client/errors/install.ts +30 -6
  22. package/src/compat/client/errors/lazy.ts +32 -2
  23. package/src/compat/next/client-script.tsx +1 -0
  24. package/src/compat/next/config-headers.ts +139 -0
  25. package/src/compat/next/config-loader.ts +32 -2
  26. package/src/compat/next/dynamic.tsx +1 -0
  27. package/src/compat/next/error.tsx +1 -0
  28. package/src/compat/next/form.tsx +1 -0
  29. package/src/compat/next/head.tsx +1 -0
  30. package/src/compat/next/headers.ts +12 -6
  31. package/src/compat/next/image/client.tsx +1 -0
  32. package/src/compat/next/image.tsx +1 -0
  33. package/src/compat/next/legacy-image.tsx +1 -0
  34. package/src/compat/next/legacy-request-apis.ts +62 -0
  35. package/src/compat/next/link.tsx +1 -0
  36. package/src/compat/next/rewrites.ts +7 -0
  37. package/src/compat/next/script.tsx +1 -0
  38. package/src/compat/register/bundler.ts +6 -1
  39. package/src/compat/segment/loading-boundary.ts +1 -5
  40. package/src/compat/validation/validate.ts +10 -2
  41. package/src/config.ts +12 -1
  42. package/src/css/build.ts +111 -4
  43. package/src/dev/restart/global-css.ts +3 -3
  44. package/src/dev/restart/route-facts.ts +3 -3
  45. package/src/dev/server.ts +2 -2
  46. package/src/render/renderer.ts +2 -2
  47. package/src/render/slots.tsx +1 -0
  48. package/src/request/context.ts +15 -0
  49. package/src/runtime/loader.ts +2 -2
  50. package/src/runtime/module-cache.ts +12 -5
  51. package/src/runtime/modules.ts +44 -31
  52. package/src/runtime/vendor-build.ts +5 -7
  53. package/src/runtime/vendor.ts +29 -13
  54. package/src/types.ts +10 -1
  55. package/src/utils/code.ts +5 -0
  56. package/src/utils/dev-profile.ts +1 -1
  57. package/src/utils/trace-flags.ts +66 -0
@@ -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
  */
@@ -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
+ }