@wular/pnext 0.0.2 → 0.0.4

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 (64) hide show
  1. package/README.md +76 -20
  2. package/package.json +3 -2
  3. package/reference/data/bench.json +513 -0
  4. package/reference/performance.md +75 -48
  5. package/src/api/router/runtime.ts +60 -21
  6. package/src/cache/context.ts +4 -1
  7. package/src/cli/build.ts +37 -5
  8. package/src/cli/dev.ts +6 -0
  9. package/src/cli/index.ts +17 -3
  10. package/src/cli/request-pipeline.ts +1340 -0
  11. package/src/cli/server-entry.ts +180 -0
  12. package/src/cli/start.ts +39 -1311
  13. package/src/client/build.ts +59 -20
  14. package/src/client/chunk-fold.ts +40 -0
  15. package/src/client/compat-surface.ts +175 -0
  16. package/src/client/entry.ts +67 -52
  17. package/src/compat/actions/action-client.ts +8 -1
  18. package/src/compat/actions/action-dispatch.ts +11 -1
  19. package/src/compat/actions/discovery.ts +23 -6
  20. package/src/compat/bundler/optimize-package-imports.ts +5 -1
  21. package/src/compat/bundler/worker.ts +2 -1
  22. package/src/compat/client/errors/bare-boundary.ts +32 -0
  23. package/src/compat/client/errors/error-boundary.ts +1 -15
  24. package/src/compat/client/errors/primitive-throw.ts +16 -0
  25. package/src/compat/client/link-status.ts +1 -1
  26. package/src/compat/css/lightningcss.ts +2 -1
  27. package/src/compat/css/modules.ts +4 -3
  28. package/src/compat/lifecycle/instrumentation-client.ts +1 -1
  29. package/src/compat/lifecycle/instrumentation.ts +5 -2
  30. package/src/compat/next/config-loader.ts +33 -5
  31. package/src/compat/next/dynamic.tsx +9 -5
  32. package/src/compat/next/link-validation-transform.ts +5 -1
  33. package/src/compat/next/link.tsx +51 -58
  34. package/src/compat/pages/client-plugin.ts +2 -1
  35. package/src/compat/react/action-state.ts +159 -0
  36. package/src/compat/react/client-lite.ts +74 -0
  37. package/src/compat/react/hooks-extra.ts +92 -0
  38. package/src/compat/react/parity.ts +128 -0
  39. package/src/compat/react/preact.ts +33 -420
  40. package/src/compat/react/server-inserted-html.ts +14 -7
  41. package/src/compat/react/use.ts +72 -0
  42. package/src/compat/register/actions.ts +27 -7
  43. package/src/compat/register/segment.ts +16 -6
  44. package/src/config.ts +15 -1
  45. package/src/css/build.ts +13 -2
  46. package/src/dev/imports.ts +34 -5
  47. package/src/dev/module-cache.ts +19 -0
  48. package/src/dev/module-transform.ts +7 -1
  49. package/src/dev/server.ts +91 -22
  50. package/src/dynamic/source.ts +36 -27
  51. package/src/ppr.ts +5 -4
  52. package/src/proxy.ts +5 -1
  53. package/src/render/island-context.ts +21 -3
  54. package/src/render/renderer.ts +102 -26
  55. package/src/resolve/engine.ts +12 -2
  56. package/src/resolve/scan-facts.ts +239 -1
  57. package/src/routing/href.ts +4 -5
  58. package/src/routing/routes.ts +26 -41
  59. package/src/runtime/server.ts +8 -4
  60. package/src/runtime/vendor.ts +1 -1
  61. package/src/typegen.ts +3 -3
  62. package/src/utils/esbuild.ts +58 -0
  63. package/src/utils/fs.ts +14 -2
  64. package/src/utils/native-require.ts +28 -0
@@ -10,7 +10,11 @@
10
10
  // The same parse also backs `rewriteFacts`: the record-shaped rewrite passes (specifier aliasing, namespace
11
11
  // imports, import.meta.url) splice byte spans off it instead of re-scanning the source with their own
12
12
  // regexes.
13
- import { parseSync } from 'oxc-parser';
13
+ // Lazy: the oxc-parser native binding costs ~12.6 MB RSS; load it only when a parse happens.
14
+ const parseSync: typeof import('oxc-parser').parseSync = (...args) =>
15
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
16
+ loadNative(() => require('oxc-parser') as typeof import('oxc-parser')).parseSync(...args);
17
+ import { loadNative } from '../utils/native-require';
14
18
  import { spliceSource } from '../dev/module-transform';
15
19
 
16
20
  export interface ScanEdge {
@@ -71,6 +75,33 @@ export interface RewriteFacts {
71
75
  unreliable: boolean;
72
76
  }
73
77
 
78
+ /**
79
+ * A `name(loader, options?)` call whose loader statically targets one module —
80
+ * the shape `dynamic()` extraction needs. Emitted for every identifier-callee
81
+ * call with such a loader; consumers gate on their own `dynamic` binding set.
82
+ */
83
+ export interface DynamicCallFact {
84
+ name: string;
85
+ /** Callee identifier start — aligns with the textual call scan's index. */
86
+ start: number;
87
+ /** Call end (past the closing paren). */
88
+ end: number;
89
+ /** Loader target, cooked value. */
90
+ specifier: string;
91
+ /** Span of the quoted specifier literal, quotes included. */
92
+ specifierStart: number;
93
+ specifierEnd: number;
94
+ /** From the `.then(m => m.X)` arm (or awaited member); `default` otherwise. */
95
+ exportName: string;
96
+ /** First arg is a string literal (`dynamic('./x')` form), not a loader fn. */
97
+ literal?: boolean;
98
+ /** End of the loader argument — options/appended args follow it. */
99
+ loaderEnd: number;
100
+ /** Second-argument span, when present. */
101
+ optionsStart?: number;
102
+ optionsEnd?: number;
103
+ }
104
+
74
105
  export interface ScanFacts {
75
106
  /** Static value imports and `export … from` re-exports, in source order. */
76
107
  imports: ScanEdge[];
@@ -84,6 +115,8 @@ export interface ScanFacts {
84
115
  exportStars: string[];
85
116
  /** A real `'use client'` directive — prologue position only, never a comment. */
86
117
  useClient: boolean;
118
+ /** Loader-shaped calls (see `DynamicCallFact`); only sources that can bind `dynamic` are walked. */
119
+ dynamicCalls: DynamicCallFact[];
87
120
  }
88
121
 
89
122
  const stylesheetOrData = /\.(?:css|scss|sass|less|styl|json)$/;
@@ -95,6 +128,7 @@ const emptyFacts: ScanFacts = {
95
128
  exportNames: [],
96
129
  exportStars: [],
97
130
  useClient: false,
131
+ dynamicCalls: [],
98
132
  };
99
133
 
100
134
  const emptyRewriteFacts: RewriteFacts = {
@@ -134,6 +168,15 @@ export function moduleExportStars(source: string, sourcefile = 'source.tsx') {
134
168
  return scanFacts(sourcefile, source).exportStars;
135
169
  }
136
170
 
171
+ /**
172
+ * AST-derived `dynamic()` loader facts — the single source of truth. A call
173
+ * without a fact is an unanalyzable loader: no detection, nothing to splice.
174
+ */
175
+ export function dynamicCallFacts(source: string, file = 'module.tsx'): DynamicCallFact[] {
176
+ if (stylesheetOrData.test(file)) return [];
177
+ return parsed(file, source).scan.dynamicCalls;
178
+ }
179
+
137
180
  /** Module-record spans for the rewrite passes (see `RewriteFacts`). */
138
181
  export function rewriteFacts(file: string, source: string): RewriteFacts {
139
182
  if (stylesheetOrData.test(file)) return emptyRewriteFacts;
@@ -323,6 +366,12 @@ function parseFacts(file: string, source: string, lang: ParserLang) {
323
366
  exportNames,
324
367
  exportStars,
325
368
  useClient: hasUseClient(source, result),
369
+ // `result.program` deserializes the whole AST: walk it only for sources
370
+ // that can bind `dynamic()` at all (same cheap gate as dynamic/source.ts).
371
+ dynamicCalls:
372
+ source.includes('@wular/pnext') || source.includes('next/dynamic')
373
+ ? collectDynamicCalls(result.program as unknown as AstNode)
374
+ : [],
326
375
  },
327
376
  rewrite: {
328
377
  edges,
@@ -465,6 +514,195 @@ function literalSpecifier(raw: string) {
465
514
  return /^\s*(['"])([^'"]*)\1\s*$/.exec(raw)?.[2] || undefined;
466
515
  }
467
516
 
517
+ interface AstNode {
518
+ type: string;
519
+ start: number;
520
+ end: number;
521
+ [key: string]: unknown;
522
+ }
523
+
524
+ function isNode(value: unknown): value is AstNode {
525
+ return typeof value === 'object' && value !== null && typeof (value as AstNode).type === 'string';
526
+ }
527
+
528
+ function child(node: AstNode | undefined, key: string): AstNode | undefined {
529
+ const value = node?.[key];
530
+ return isNode(value) ? value : undefined;
531
+ }
532
+
533
+ function collectDynamicCalls(program: AstNode): DynamicCallFact[] {
534
+ const calls: DynamicCallFact[] = [];
535
+ const visit = (value: unknown) => {
536
+ if (Array.isArray(value)) {
537
+ for (const item of value) visit(item);
538
+ return;
539
+ }
540
+ if (!isNode(value)) return;
541
+ if (value.type === 'CallExpression') {
542
+ const fact = dynamicCallFact(value);
543
+ if (fact) calls.push(fact);
544
+ }
545
+ for (const key in value) {
546
+ if (key === 'type' || key === 'loc' || key === 'range') continue;
547
+ const entry = value[key];
548
+ if (entry && typeof entry === 'object') visit(entry);
549
+ }
550
+ };
551
+ visit(program.body);
552
+ return calls.sort((a, b) => a.start - b.start);
553
+ }
554
+
555
+ function dynamicCallFact(call: AstNode): DynamicCallFact | undefined {
556
+ const callee = child(call, 'callee');
557
+ if (callee?.type !== 'Identifier') return undefined;
558
+ const args = call.arguments;
559
+ if (!Array.isArray(args) || !isNode(args[0])) return undefined;
560
+ const loader = args[0];
561
+
562
+ let target: { specifier: AstNode; exportName: string } | undefined;
563
+ let literal: boolean | undefined;
564
+ if (stringValue(loader) !== undefined) {
565
+ target = { specifier: loader, exportName: 'default' };
566
+ literal = true;
567
+ } else if (loader.type === 'ArrowFunctionExpression' || loader.type === 'FunctionExpression') {
568
+ target = loaderImportTarget(loader) ?? firstImportTarget(loader);
569
+ }
570
+ if (!target) return undefined;
571
+
572
+ const specifier = stringValue(target.specifier);
573
+ if (specifier === undefined) return undefined;
574
+ const options = isNode(args[1]) ? args[1] : undefined;
575
+ return {
576
+ name: callee.name as string,
577
+ start: callee.start,
578
+ end: call.end,
579
+ specifier,
580
+ specifierStart: target.specifier.start,
581
+ specifierEnd: target.specifier.end,
582
+ exportName: target.exportName,
583
+ ...(literal ? { literal } : {}),
584
+ loaderEnd: loader.end,
585
+ ...(options ? { optionsStart: options.start, optionsEnd: options.end } : {}),
586
+ };
587
+ }
588
+
589
+ function stringValue(node: AstNode | undefined): string | undefined {
590
+ if (!node) return undefined;
591
+ if (node.type !== 'Literal' && node.type !== 'StringLiteral') return undefined;
592
+ return typeof node.value === 'string' ? node.value : undefined;
593
+ }
594
+
595
+ /** Past parens, `await`, `as`/`!` and optional-chain wrappers to the loader's real expression. */
596
+ function unwrapExpression(node: AstNode | undefined): AstNode | undefined {
597
+ let current = node;
598
+ for (;;) {
599
+ if (!current) return undefined;
600
+ if (current.type === 'ParenthesizedExpression' || current.type === 'ChainExpression') {
601
+ current = child(current, 'expression');
602
+ } else if (current.type === 'AwaitExpression') {
603
+ current = child(current, 'argument');
604
+ } else if (current.type === 'TSAsExpression' || current.type === 'TSNonNullExpression' || current.type === 'TSSatisfiesExpression') {
605
+ current = child(current, 'expression');
606
+ } else {
607
+ return current;
608
+ }
609
+ }
610
+ }
611
+
612
+ /** The expression a loader function resolves to: its expression body or first `return`. */
613
+ function loaderResult(fn: AstNode): AstNode | undefined {
614
+ const body = child(fn, 'body');
615
+ if (!body) return undefined;
616
+ if (body.type !== 'BlockStatement' && body.type !== 'FunctionBody') return unwrapExpression(body);
617
+ const statements = body.body;
618
+ if (!Array.isArray(statements)) return undefined;
619
+ for (const statement of statements) {
620
+ if (isNode(statement) && statement.type === 'ReturnStatement') {
621
+ return unwrapExpression(child(statement, 'argument'));
622
+ }
623
+ }
624
+ return undefined;
625
+ }
626
+
627
+ function importSource(node: AstNode | undefined): AstNode | undefined {
628
+ if (node?.type !== 'ImportExpression') return undefined;
629
+ const source = child(node, 'source');
630
+ return source && stringValue(source) !== undefined ? source : undefined;
631
+ }
632
+
633
+ /**
634
+ * Statically resolve a loader body: `import('x')`, `import('x').then(m => m.X)`
635
+ * (any param/arrow formatting), or `(await import('x')).X`.
636
+ */
637
+ function loaderImportTarget(fn: AstNode): { specifier: AstNode; exportName: string } | undefined {
638
+ const result = loaderResult(fn);
639
+ if (!result) return undefined;
640
+
641
+ const direct = importSource(result);
642
+ if (direct) return { specifier: direct, exportName: 'default' };
643
+
644
+ if (result.type === 'MemberExpression' && result.computed !== true) {
645
+ const source = importSource(unwrapExpression(child(result, 'object')));
646
+ const property = child(result, 'property');
647
+ if (source && property?.type === 'Identifier') {
648
+ return { specifier: source, exportName: property.name as string };
649
+ }
650
+ }
651
+
652
+ if (result.type !== 'CallExpression') return undefined;
653
+ const callee = child(result, 'callee');
654
+ if (callee?.type !== 'MemberExpression' || callee.computed === true) return undefined;
655
+ if (child(callee, 'property')?.name !== 'then') return undefined;
656
+ const source = importSource(unwrapExpression(child(callee, 'object')));
657
+ if (!source) return undefined;
658
+ return { specifier: source, exportName: thenArmExport(result) ?? 'default' };
659
+ }
660
+
661
+ /** `X` of a `.then(m => m.X)` arm; undefined for any other arm shape. */
662
+ function thenArmExport(thenCall: AstNode): string | undefined {
663
+ const args = thenCall.arguments;
664
+ if (!Array.isArray(args) || !isNode(args[0])) return undefined;
665
+ const arm = args[0];
666
+ if (arm.type !== 'ArrowFunctionExpression' && arm.type !== 'FunctionExpression') return undefined;
667
+ const params = arm.params;
668
+ const param = Array.isArray(params) && isNode(params[0]) ? params[0] : undefined;
669
+ if (param?.type !== 'Identifier') return undefined;
670
+ const result = loaderResult(arm);
671
+ if (result?.type !== 'MemberExpression' || result.computed === true) return undefined;
672
+ const object = unwrapExpression(child(result, 'object'));
673
+ if (object?.type !== 'Identifier' || object.name !== param.name) return undefined;
674
+ const property = child(result, 'property');
675
+ return property?.type === 'Identifier' ? (property.name as string) : undefined;
676
+ }
677
+
678
+ /**
679
+ * Fallback for loader shapes the structured pass cannot follow: the first
680
+ * literal `import()` anywhere in the loader, default export — mirrors what the
681
+ * textual extraction always did for these.
682
+ */
683
+ function firstImportTarget(fn: AstNode): { specifier: AstNode; exportName: string } | undefined {
684
+ let found: AstNode | undefined;
685
+ const visit = (value: unknown) => {
686
+ if (found) return;
687
+ if (Array.isArray(value)) {
688
+ for (const item of value) visit(item);
689
+ return;
690
+ }
691
+ if (!isNode(value)) return;
692
+ const source = importSource(value);
693
+ if (source) {
694
+ found = source;
695
+ return;
696
+ }
697
+ for (const key in value) {
698
+ const entry = value[key];
699
+ if (entry && typeof entry === 'object') visit(entry);
700
+ }
701
+ };
702
+ visit(fn.body);
703
+ return found ? { specifier: found, exportName: 'default' } : undefined;
704
+ }
705
+
468
706
  type ParserLang = 'ts' | 'tsx';
469
707
 
470
708
  // `.ts` forbids JSX (`<T>value` is a type assertion there); everything else —
@@ -28,14 +28,13 @@ declare global {
28
28
  // once per process by the server runtime from the resolved config. On the
29
29
  // client the value is read from the injected window global (register-render's
30
30
  // trailingSlashScript), mirroring basePath / skipTrailingSlash.
31
- let trailingSlashUrls = false;
32
-
31
+ // Anchored on globalThis, not a module local: this module is dual-copied into
32
+ // the client/prebundled bundles, where setTrailingSlashUrls never runs.
33
33
  export function setTrailingSlashUrls(enabled: boolean) {
34
- trailingSlashUrls = enabled;
34
+ globalThis.__PNEXT_TRAILING_SLASH__ = enabled;
35
35
  }
36
36
 
37
37
  function isTrailingSlashEnabled(): boolean {
38
- if (trailingSlashUrls) return true;
39
38
  if (process.browser || typeof window !== 'undefined') return window.__PNEXT_TRAILING_SLASH__ === true;
40
39
  return globalThis.__PNEXT_TRAILING_SLASH__ === true;
41
40
  }
@@ -76,7 +75,7 @@ export function withBasePath(path: string) {
76
75
  export function applyTrailingSlash(pathname: string) {
77
76
  const [, path = '', rest = ''] = /^([^?#]*)([\s\S]*)$/.exec(pathname) ?? [];
78
77
  if (!path.startsWith('/')) return pathname;
79
- if (trailingSlashUrls) {
78
+ if (isTrailingSlashEnabled()) {
80
79
  // Add the canonical trailing slash (skip root, already-slashed, and files).
81
80
  if (path === '/' || path.endsWith('/') || /\.[^/]+$/.test(path)) return pathname;
82
81
  return `${path}/${rest}`;
@@ -15,7 +15,7 @@ import {
15
15
  resolveModuleAlias,
16
16
  resolvePackageSpecifier,
17
17
  } from '../resolve/imports';
18
- import { scanFacts } from '../resolve/scan-facts';
18
+ import { dynamicCallFacts, scanFacts, type DynamicCallFact } from '../resolve/scan-facts';
19
19
  import { readSourceSync } from '../resolve/source-text';
20
20
  import { globalCssSourcesForPaths } from '../css';
21
21
  import {
@@ -340,7 +340,11 @@ export function setRouteFactsStore(store: RouteFactsStore | undefined) {
340
340
  factsStore = store;
341
341
  }
342
342
 
343
- function withDeferredFacts(base: RoutePathEntry, compute: () => RouteFacts): RouteManifestEntry {
343
+ function withDeferredFacts(
344
+ appPath: string,
345
+ base: RoutePathEntry,
346
+ compute: () => RouteFacts,
347
+ ): RouteManifestEntry {
344
348
  const entry = base as RouteManifestEntry;
345
349
  let facts: RouteFacts | undefined;
346
350
  // Consumers still assign some of these (the build marks needsRouterEntry), so
@@ -355,7 +359,10 @@ function withDeferredFacts(base: RoutePathEntry, compute: () => RouteFacts): Rou
355
359
  get() {
356
360
  if (overrides.has(field)) return overrides.get(field);
357
361
  if (!facts) {
358
- const key = `${base.kind}:${base.id}`;
362
+ // Route ids repeat across apps ('page:/' everywhere), so the app the
363
+ // facts were derived in is part of the key - otherwise one app's
364
+ // record answers another app's lookup in the same process.
365
+ const key = `${appPath}\0${base.kind}:${base.id}`;
359
366
  const stored = factsStore?.load(key);
360
367
  facts = stored ?? withDirCache(compute);
361
368
  if (!stored) factsStore?.save(key, facts);
@@ -415,6 +422,7 @@ function buildRouteTable(appPath: string, files: string[]): RouteManifestEntry[]
415
422
  if (!metadataParts) continue;
416
423
  routes.push(
417
424
  withDeferredFacts(
425
+ appPath,
418
426
  {
419
427
  id: `metadata-${routeId(metadataParts.route)}`,
420
428
  kind: 'handler',
@@ -462,6 +470,7 @@ function buildRouteTable(appPath: string, files: string[]): RouteManifestEntry[]
462
470
  const interception = parts.interception;
463
471
  routes.push(
464
472
  withDeferredFacts(
473
+ appPath,
465
474
  {
466
475
  id: interception ? `intercept-${sanitizeIdPart(routeDir)}` : routeId(parts.route || '/'),
467
476
  kind,
@@ -844,6 +853,7 @@ function appendSlotDerivedRoutes(
844
853
  // interceptor during slot rendering. Kept minimal on purpose.
845
854
  routes.push(
846
855
  withDeferredFacts(
856
+ appPath,
847
857
  {
848
858
  id: `intercept-${sanitizeIdPart(candidate.relative.replace(pageOrRouteTrailing(), ''))}`,
849
859
  kind: 'page',
@@ -905,6 +915,7 @@ function appendSlotDerivedRoutes(
905
915
 
906
916
  routes.push(
907
917
  withDeferredFacts(
918
+ appPath,
908
919
  {
909
920
  id: `slot-${routeId(parts.route || '/')}`,
910
921
  kind: 'page',
@@ -1050,6 +1061,7 @@ function appendDefaultDerivedRoutes(
1050
1061
 
1051
1062
  routes.push(
1052
1063
  withDeferredFacts(
1064
+ appPath,
1053
1065
  {
1054
1066
  id: `default-${routeId(parts.route || '/')}`,
1055
1067
  kind: 'page',
@@ -2264,21 +2276,14 @@ function dynamicImportEdgesFromSource(
2264
2276
  if (dynamicNames.size === 0) return imports;
2265
2277
 
2266
2278
  const optionObjects = dynamicOptionObjects(source);
2267
- const dynamicPattern =
2268
- /import\s*\(\s*['"]([^'"]+)['"]\s*\)(?:\s*\.then\s*\(\s*([A-Za-z_$][\w$]*)\s*=>\s*\2\.([A-Za-z_$][\w$]*)\s*\))?/g;
2269
- let match: RegExpExecArray | null;
2270
-
2271
- while ((match = dynamicPattern.exec(source))) {
2272
- const [, specifier, _moduleName, exportName] = match;
2273
- if (!specifier) continue;
2274
- if (!isDynamicCallImport(source, match.index, dynamicNames)) continue;
2275
-
2276
- const resolved = resolveModuleEdge(root, file, specifier);
2279
+ for (const fact of dynamicCallFacts(source, file)) {
2280
+ if (!dynamicNames.has(fact.name)) continue;
2281
+ const resolved = resolveModuleEdge(root, file, fact.specifier);
2277
2282
  if (!resolved) continue;
2278
2283
  imports.push({
2279
2284
  file: resolved,
2280
- exports: [exportName ?? 'default'],
2281
- dynamic: dynamicOptionsForImport(source, dynamicPattern.lastIndex, optionObjects),
2285
+ exports: [fact.exportName],
2286
+ dynamic: dynamicOptionsForFact(source, fact, optionObjects),
2282
2287
  });
2283
2288
  }
2284
2289
 
@@ -2298,35 +2303,15 @@ function dynamicOptionObjects(source: string) {
2298
2303
  return options;
2299
2304
  }
2300
2305
 
2301
- function isDynamicCallImport(source: string, importIndex: number, dynamicNames: Set<string>) {
2302
- const prefix = source.slice(Math.max(0, importIndex - 120), importIndex);
2303
- const dynamicIndex = nextDynamicCallIndexFromEnd(prefix, dynamicNames);
2304
- if (dynamicIndex === -1) return false;
2305
- return !/[;\n]\s*$/.test(prefix.slice(dynamicIndex));
2306
- }
2307
-
2308
- function nextDynamicCallIndexFromEnd(source: string, dynamicNames: Set<string>) {
2309
- let next = -1;
2310
- for (const name of dynamicNames) {
2311
- const index = source.lastIndexOf(name);
2312
- if (index !== -1 && index > next) next = index;
2313
- }
2314
- return next;
2315
- }
2316
-
2317
- function dynamicOptionsForImport(
2306
+ function dynamicOptionsForFact(
2318
2307
  source: string,
2319
- endIndex: number,
2308
+ fact: DynamicCallFact,
2320
2309
  optionObjects: Map<string, ClientDynamicReference>,
2321
2310
  ) {
2322
- const tail = source.slice(endIndex, endIndex + 300);
2323
- const inlineMatch = /^\s*,\s*({[\s\S]*?})\s*,?\s*\)/.exec(tail);
2324
- if (inlineMatch?.[1]) return dynamicOptionsFromSource(inlineMatch[1]);
2325
-
2326
- const identifierMatch = /^\s*,\s*([A-Za-z_$][\w$]*)\s*,?\s*\)/.exec(tail);
2327
- if (identifierMatch?.[1]) return optionObjects.get(identifierMatch[1]) ?? {};
2328
-
2329
- return {};
2311
+ if (fact.optionsStart === undefined || fact.optionsEnd === undefined) return {};
2312
+ const options = source.slice(fact.optionsStart, fact.optionsEnd);
2313
+ if (/^[A-Za-z_$][\w$]*$/.test(options)) return optionObjects.get(options) ?? {};
2314
+ return dynamicOptionsFromSource(options);
2330
2315
  }
2331
2316
 
2332
2317
  function dynamicOptionsFromSource(source?: string): ClientDynamicReference {
@@ -4,7 +4,8 @@ import { existsSync, readFileSync, realpathSync, statSync } from 'node:fs';
4
4
  import { copyFile, mkdir, readFile, rm, stat, symlink, writeFile } from 'node:fs/promises';
5
5
  import path from 'node:path';
6
6
  import { fileURLToPath } from 'node:url';
7
- import { build, transform, type Loader, type Metafile, type OnLoadResult, type OnResolveResult, type Plugin } from 'esbuild';
7
+ import type { Loader, Metafile, OnLoadResult, OnResolveResult, Plugin } from 'esbuild';
8
+ import { build, transform } from '../utils/esbuild';
8
9
  import {
9
10
  applyBundledSourceTransforms,
10
11
  applyServerSourcePreTransforms,
@@ -2836,9 +2837,12 @@ async function bundleExternalPackageImpl(
2836
2837
  resolvedEntry?.match(/\.[cm]?tsx?$/) &&
2837
2838
  !getExternalPackagePolicy().transpile(packageName)
2838
2839
  ) {
2839
- throw new Error(
2840
- `${path.relative(config.root, resolvedEntry)}\nModule parse failed: Unexpected token`,
2841
- );
2840
+ const parseFailure = `${path.relative(config.root, resolvedEntry)}\nModule parse failed: Unexpected token`;
2841
+ // Printed directly as well: the digest log path inspects the error's stack,
2842
+ // which some runtimes emit without the message - the CLI output must always
2843
+ // carry the parse failure (Next's transpilePackages error contract).
2844
+ console.error(`⨯ Error: ${parseFailure}`);
2845
+ throw new Error(parseFailure);
2842
2846
  }
2843
2847
  const entry =
2844
2848
  requiredEntry ??
@@ -37,7 +37,7 @@ import { appendFileSync, existsSync, mkdirSync } from 'node:fs';
37
37
  import { copyFile, mkdir, readFile } from 'node:fs/promises';
38
38
  import path from 'node:path';
39
39
  import { fileURLToPath } from 'node:url';
40
- import { build } from 'esbuild';
40
+ import { build } from '../utils/esbuild';
41
41
  import { isCommonJsModuleSource } from '../resolve/imports';
42
42
  import { outputSpecifiers } from '../dev/module-transform';
43
43
  import { escapeRegex, isIdentifier, uniqueIdentifier } from '../utils/source';
package/src/typegen.ts CHANGED
@@ -25,7 +25,7 @@ export async function writeTypegen(
25
25
  allRoutes: RouteManifestEntry[],
26
26
  ): Promise<TypegenResult> {
27
27
  const routes = allRoutes.filter(route => !route.interception && !route.synthetic);
28
- const file = path.join(config.outPath, 'types', 'pnext.gen.d.ts');
28
+ const file = path.join(config.typesPath, 'pnext.gen.d.ts');
29
29
  const source = typegenSource(routes);
30
30
  await writeIfChanged(file, source);
31
31
  const aliases = await writeRouteAliases(config, routes);
@@ -57,7 +57,7 @@ function routeEntry(route: RouteManifestEntry) {
57
57
  }
58
58
 
59
59
  async function writeChecks(config: ResolvedConfig, routes: RouteManifestEntry[]) {
60
- const checksDir = path.join(config.outPath, 'types', 'checks');
60
+ const checksDir = path.join(config.typesPath, 'checks');
61
61
  await rm(checksDir, { recursive: true, force: true });
62
62
  await ensureDir(checksDir);
63
63
 
@@ -72,7 +72,7 @@ async function writeChecks(config: ResolvedConfig, routes: RouteManifestEntry[])
72
72
  }
73
73
 
74
74
  async function writeRouteAliases(config: ResolvedConfig, routes: RouteManifestEntry[]) {
75
- const aliasesDir = path.join(config.outPath, 'types', 'app');
75
+ const aliasesDir = path.join(config.typesPath, 'app');
76
76
  await rm(aliasesDir, { recursive: true, force: true });
77
77
  await ensureDir(aliasesDir);
78
78
 
@@ -0,0 +1,58 @@
1
+ // Lazy esbuild facade: importing 'esbuild' eagerly costs ~2.7 MB RSS and its
2
+ // first build()/transform() call spawns a resident ~10-45 MB service child, so
3
+ // the library must not load until something actually compiles.
4
+ import type * as Esbuild from 'esbuild';
5
+ import { loadNative } from './native-require';
6
+
7
+ let esbuild: typeof Esbuild | undefined;
8
+
9
+ function load(): typeof Esbuild {
10
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
11
+ return (esbuild ??= loadNative(() => require('esbuild') as typeof Esbuild));
12
+ }
13
+
14
+ // In-flight compiles, so a stop() never kills the service child out from under
15
+ // one: esbuild drops the pending call's promise and it never settles.
16
+ let inFlight = 0;
17
+ let stopWhenIdle = false;
18
+
19
+ function track<T>(promise: Promise<T>): Promise<T> {
20
+ inFlight += 1;
21
+ return promise.finally(() => {
22
+ inFlight -= 1;
23
+ if (inFlight === 0 && stopWhenIdle) {
24
+ stopWhenIdle = false;
25
+ void esbuild?.stop();
26
+ }
27
+ });
28
+ }
29
+
30
+ export const build: typeof Esbuild.build = options => track(load().build(options));
31
+
32
+ export const transform: typeof Esbuild.transform = (input, options) =>
33
+ track(load().transform(input, options));
34
+
35
+ /**
36
+ * Spawn the resident service child ahead of the first real compile. Fire-and-forget: the spawn and
37
+ * its handshake are subprocess work, so a caller holding wall-clock it does not control (a dev boot,
38
+ * which compiles nothing before it listens) can absorb a cost the first compile would otherwise pay.
39
+ */
40
+ export function warmEsbuildService(): void {
41
+ void transform('', { loader: 'js' }).catch(() => undefined);
42
+ }
43
+
44
+ /**
45
+ * Kill the resident esbuild service child (a prod server is done compiling
46
+ * after its boot config build). No-op when esbuild never loaded; the service
47
+ * respawns transparently on the next build()/transform() call. Deferred while a
48
+ * compile is in flight — instrumentation register() bundles concurrently with
49
+ * server boot, and killing the service mid-build hangs it forever.
50
+ */
51
+ export function stopEsbuildService(): void {
52
+ if (!esbuild) return;
53
+ if (inFlight > 0) {
54
+ stopWhenIdle = true;
55
+ return;
56
+ }
57
+ void esbuild.stop();
58
+ }
package/src/utils/fs.ts CHANGED
@@ -92,9 +92,21 @@ export function listFilesSync(root: string): string[] {
92
92
  return out;
93
93
  }
94
94
 
95
- export async function ensureEmptyDir(dir: string) {
96
- await rm(dir, { recursive: true, force: true });
95
+ /** Empty `dir`, leaving the named top-level entries (and their contents) in place. */
96
+ export async function ensureEmptyDir(dir: string, keep: readonly string[] = []) {
97
+ if (keep.length === 0) {
98
+ await rm(dir, { recursive: true, force: true });
99
+ await mkdir(dir, { recursive: true });
100
+ return;
101
+ }
97
102
  await mkdir(dir, { recursive: true });
103
+ const preserved = new Set(keep);
104
+ const entries = await readdir(dir).catch(() => [] as string[]);
105
+ await Promise.all(
106
+ entries
107
+ .filter(entry => !preserved.has(entry))
108
+ .map(entry => rm(path.join(dir, entry), { recursive: true, force: true })),
109
+ );
98
110
  }
99
111
 
100
112
  export async function ensureDir(dir: string) {
@@ -0,0 +1,28 @@
1
+ // Native-binding loader guard.
2
+ //
3
+ // The lazy `require('esbuild' | 'oxc-*' | 'lightningcss')` facades defer a native binding until
4
+ // something actually compiles/parses/resolves - which means the FIRST load can land anywhere, including
5
+ // inside a render the compat edge runtime is wrapping. That runtime swaps a `process` proxy onto
6
+ // globalThis which hides `version`/`versions` (Next parity: user code must not detect Node there), and
7
+ // these loaders read `process.versions.node` / `process.versions.pnp` while initializing - so the
8
+ // require throws. Load them against the real process instead; the module cache then holds a good copy
9
+ // for every later call.
10
+ import realProcess from 'node:process';
11
+
12
+ export function loadNative<T>(load: () => T): T {
13
+ const globalObject = globalThis as typeof globalThis & { process?: NodeJS.Process };
14
+ if (globalObject.process === realProcess) return load();
15
+ const previous = Object.getOwnPropertyDescriptor(globalObject, 'process');
16
+ Object.defineProperty(globalObject, 'process', {
17
+ configurable: true,
18
+ enumerable: false,
19
+ writable: true,
20
+ value: realProcess,
21
+ });
22
+ try {
23
+ return load();
24
+ } finally {
25
+ if (previous) Object.defineProperty(globalObject, 'process', previous);
26
+ else delete (globalObject as { process?: NodeJS.Process }).process;
27
+ }
28
+ }