@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.
- package/README.md +76 -20
- package/package.json +3 -2
- package/reference/data/bench.json +513 -0
- package/reference/performance.md +75 -48
- package/src/api/router/runtime.ts +60 -21
- package/src/cache/context.ts +4 -1
- package/src/cli/build.ts +37 -5
- package/src/cli/dev.ts +6 -0
- package/src/cli/index.ts +17 -3
- package/src/cli/request-pipeline.ts +1340 -0
- package/src/cli/server-entry.ts +180 -0
- package/src/cli/start.ts +39 -1311
- package/src/client/build.ts +59 -20
- package/src/client/chunk-fold.ts +40 -0
- package/src/client/compat-surface.ts +175 -0
- package/src/client/entry.ts +67 -52
- package/src/compat/actions/action-client.ts +8 -1
- package/src/compat/actions/action-dispatch.ts +11 -1
- package/src/compat/actions/discovery.ts +23 -6
- package/src/compat/bundler/optimize-package-imports.ts +5 -1
- package/src/compat/bundler/worker.ts +2 -1
- package/src/compat/client/errors/bare-boundary.ts +32 -0
- package/src/compat/client/errors/error-boundary.ts +1 -15
- package/src/compat/client/errors/primitive-throw.ts +16 -0
- package/src/compat/client/link-status.ts +1 -1
- package/src/compat/css/lightningcss.ts +2 -1
- package/src/compat/css/modules.ts +4 -3
- package/src/compat/lifecycle/instrumentation-client.ts +1 -1
- package/src/compat/lifecycle/instrumentation.ts +5 -2
- package/src/compat/next/config-loader.ts +33 -5
- package/src/compat/next/dynamic.tsx +9 -5
- package/src/compat/next/link-validation-transform.ts +5 -1
- package/src/compat/next/link.tsx +51 -58
- package/src/compat/pages/client-plugin.ts +2 -1
- package/src/compat/react/action-state.ts +159 -0
- package/src/compat/react/client-lite.ts +74 -0
- package/src/compat/react/hooks-extra.ts +92 -0
- package/src/compat/react/parity.ts +128 -0
- package/src/compat/react/preact.ts +33 -420
- package/src/compat/react/server-inserted-html.ts +14 -7
- package/src/compat/react/use.ts +72 -0
- package/src/compat/register/actions.ts +27 -7
- package/src/compat/register/segment.ts +16 -6
- package/src/config.ts +15 -1
- package/src/css/build.ts +13 -2
- package/src/dev/imports.ts +34 -5
- package/src/dev/module-cache.ts +19 -0
- package/src/dev/module-transform.ts +7 -1
- package/src/dev/server.ts +91 -22
- package/src/dynamic/source.ts +36 -27
- package/src/ppr.ts +5 -4
- package/src/proxy.ts +5 -1
- package/src/render/island-context.ts +21 -3
- package/src/render/renderer.ts +102 -26
- package/src/resolve/engine.ts +12 -2
- package/src/resolve/scan-facts.ts +239 -1
- package/src/routing/href.ts +4 -5
- package/src/routing/routes.ts +26 -41
- package/src/runtime/server.ts +8 -4
- package/src/runtime/vendor.ts +1 -1
- package/src/typegen.ts +3 -3
- package/src/utils/esbuild.ts +58 -0
- package/src/utils/fs.ts +14 -2
- 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
|
-
|
|
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 —
|
package/src/routing/href.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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 (
|
|
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}`;
|
package/src/routing/routes.ts
CHANGED
|
@@ -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(
|
|
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
|
-
|
|
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
|
|
2268
|
-
|
|
2269
|
-
|
|
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
|
|
2281
|
-
dynamic:
|
|
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
|
|
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
|
-
|
|
2308
|
+
fact: DynamicCallFact,
|
|
2320
2309
|
optionObjects: Map<string, ClientDynamicReference>,
|
|
2321
2310
|
) {
|
|
2322
|
-
|
|
2323
|
-
const
|
|
2324
|
-
if (
|
|
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 {
|
package/src/runtime/server.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
2840
|
-
|
|
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 ??
|
package/src/runtime/vendor.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
96
|
-
|
|
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
|
+
}
|