@thomas.f/prnext 0.1.18-alpha → 0.1.19-alpha
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 +2 -0
- package/build/actions.mjs +26 -14
- package/build/app.mjs +46 -25
- package/build/compiler.mjs +5 -4
- package/build/config.mjs +4 -2
- package/build/dev-css-assets.mjs +173 -0
- package/build/dev-css-update.mjs +14 -12
- package/build/dev-lazy-build.mjs +77 -0
- package/build/dynamic.mjs +13 -6
- package/build/incremental.mjs +112 -18
- package/build/index.mjs +32 -3
- package/build/lazy-dynamic-imports.mjs +137 -0
- package/build/migrate.mjs +4 -4
- package/build/migration-audit.mjs +19 -31
- package/build/migration-imports.mjs +245 -0
- package/build/migration-scenarios-runner.mjs +73 -0
- package/build/migration-scenarios.mjs +72 -0
- package/build/native-discovery.mjs +65 -5
- package/build/native-packages.mjs +2 -0
- package/build/npm-packages.mjs +1 -0
- package/build/production-builder.mjs +1 -1
- package/build/standalone.mjs +21 -3
- package/build/webpack-graph.mjs +13 -3
- package/cli.mjs +10 -1
- package/compat/app-dynamic.cjs +12 -1
- package/compat/cache-handlers.cjs +3 -1
- package/compat/flight-delta.cjs +3 -1
- package/compat/index.d.ts +1 -1
- package/compat/link.cjs +2 -2
- package/docs/ai/compatibility.md +77 -0
- package/docs/ai/routing.md +11 -0
- package/docs/ai/troubleshooting.md +79 -0
- package/package.json +21 -9
- package/runtime/app-client.mjs +119 -42
- package/runtime/atomic-file.mjs +27 -0
- package/runtime/cache-components-rsc.mjs +3 -1
- package/runtime/dev-client.mjs +7 -2
- package/runtime/dev-file-changes.mjs +123 -8
- package/runtime/dev-lazy-imports.mjs +121 -0
- package/runtime/dev-lazy-staging.mjs +14 -0
- package/runtime/dev-style-retention.mjs +15 -0
- package/runtime/development.mjs +60 -19
- package/runtime/flight-delta.mjs +28 -4
- package/runtime/fs.mjs +15 -8
- package/runtime/lazy-import-client.mjs +38 -0
- package/vendor/nft/LICENSE +7 -0
- package/vendor/nft/README.md +16 -0
- package/vendor/nft/out/analyze.d.ts +8 -0
- package/vendor/nft/out/analyze.js +1216 -0
- package/vendor/nft/out/analyze.js.map +1 -0
- package/vendor/nft/out/cli.d.ts +2 -0
- package/vendor/nft/out/cli.js +94 -0
- package/vendor/nft/out/cli.js.map +1 -0
- package/vendor/nft/out/fs.d.ts +22 -0
- package/vendor/nft/out/fs.js +106 -0
- package/vendor/nft/out/fs.js.map +1 -0
- package/vendor/nft/out/index.d.ts +4 -0
- package/vendor/nft/out/index.js +25 -0
- package/vendor/nft/out/index.js.map +1 -0
- package/vendor/nft/out/node-file-trace.d.ts +41 -0
- package/vendor/nft/out/node-file-trace.js +380 -0
- package/vendor/nft/out/node-file-trace.js.map +1 -0
- package/vendor/nft/out/resolve-dependency.d.ts +6 -0
- package/vendor/nft/out/resolve-dependency.js +427 -0
- package/vendor/nft/out/resolve-dependency.js.map +1 -0
- package/vendor/nft/out/types.d.ts +66 -0
- package/vendor/nft/out/types.js +2 -0
- package/vendor/nft/out/types.js.map +1 -0
- package/vendor/nft/out/utils/ast-helpers.js +49 -0
- package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
- package/vendor/nft/out/utils/binary-locators.js +88 -0
- package/vendor/nft/out/utils/binary-locators.js.map +1 -0
- package/vendor/nft/out/utils/get-package-base.js +30 -0
- package/vendor/nft/out/utils/get-package-base.js.map +1 -0
- package/vendor/nft/out/utils/interop-require.js +24 -0
- package/vendor/nft/out/utils/interop-require.js.map +1 -0
- package/vendor/nft/out/utils/node-version.js +10 -0
- package/vendor/nft/out/utils/node-version.js.map +1 -0
- package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
- package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
- package/vendor/nft/out/utils/special-cases.js +347 -0
- package/vendor/nft/out/utils/special-cases.js.map +1 -0
- package/vendor/nft/out/utils/static-eval.js +579 -0
- package/vendor/nft/out/utils/static-eval.js.map +1 -0
- package/vendor/nft/out/utils/types.js +2 -0
- package/vendor/nft/out/utils/types.js.map +1 -0
- package/vendor/nft/out/utils/wrappers.js +659 -0
- package/vendor/nft/out/utils/wrappers.js.map +1 -0
- package/vendor/nft/package.json +4 -0
- package/vendor/nft/upstream.json +51 -0
|
@@ -1,36 +1,27 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
1
|
+
import { readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { createHash } from 'node:crypto';
|
|
5
4
|
import { checkProject } from './check.mjs';
|
|
6
|
-
import { frameworkImportName } from './framework-imports.mjs';
|
|
7
5
|
import { packageManifest } from '../native/resolve.mjs';
|
|
6
|
+
import { auditImportGraph } from './migration-imports.mjs';
|
|
8
7
|
|
|
9
|
-
const supported = new Set([...Object.keys(packageManifest.exports).map(name => frameworkImportName(name.slice(2))), 'font/google', 'font/local', 'next-router-context', 'next-app-router-context']);
|
|
10
8
|
const ignored = new Set(['node_modules', '.git', '.next', '.prnext', '.prnext-cache', 'dist', 'build', 'out', 'coverage', 'test-results', '__tests__']);
|
|
11
9
|
const code = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
12
|
-
function walkAst(node, visit) {
|
|
13
|
-
if (!node || typeof node !== 'object') return;
|
|
14
|
-
if (typeof node.type === 'string') visit(node);
|
|
15
|
-
for (const [key, value] of Object.entries(node)) {
|
|
16
|
-
if (['loc', 'comments', 'tokens', 'extra'].includes(key)) continue;
|
|
17
|
-
if (Array.isArray(value)) for (const child of value) walkAst(child, visit);
|
|
18
|
-
else if (value && typeof value === 'object') walkAst(value, visit);
|
|
19
|
-
}
|
|
20
|
-
}
|
|
21
10
|
|
|
22
11
|
/** A bounded source audit. It never runs a build, installs packages, or edits the project. */
|
|
23
12
|
export async function auditMigration(directory) {
|
|
24
|
-
const
|
|
13
|
+
const requestedRoot = path.resolve(directory);
|
|
14
|
+
const root = await realpath(requestedRoot).catch(() => requestedRoot);
|
|
25
15
|
const preflight = await checkProject(root, { validateDependencies: false });
|
|
26
|
-
const report = { version:
|
|
16
|
+
const report = { version: 2, root, ok: false, status: 'checked', files: 0, routes: preflight.routes,
|
|
27
17
|
versions: preflight.versions, findings: [], imports: [], notes: [
|
|
28
|
-
'This source audit
|
|
18
|
+
'This source audit follows imported dependencies without executing them. A checked import is not proof that the application works. Run a production build and browser tests before switching.',
|
|
29
19
|
'Configuration files are evaluated by preflight. Application sources and package files are not modified.',
|
|
30
20
|
] };
|
|
31
21
|
const finding = (severity, id, file, line, message, action) => report.findings.push({ severity, id, file, line, message, action });
|
|
32
22
|
for (const error of preflight.errors) finding('error', 'preflight', null, null, error, 'Fix the configuration or route convention, then run the check again.');
|
|
33
23
|
let bytes = 0;
|
|
24
|
+
const sources = [];
|
|
34
25
|
async function scan(dir) {
|
|
35
26
|
for (const item of (await readdir(dir, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name))) {
|
|
36
27
|
if (ignored.has(item.name) || item.name.startsWith('.prnext-')) continue;
|
|
@@ -39,25 +30,22 @@ export async function auditMigration(directory) {
|
|
|
39
30
|
if (item.isDirectory()) { await scan(filename); continue; }
|
|
40
31
|
if (!item.isFile() || !code.test(item.name) || /(?:\.d|\.test|\.spec)\.[cm]?[jt]sx?$/.test(item.name)) continue;
|
|
41
32
|
if (++report.files > 5000 || (bytes += (await stat(filename)).size) > 32 * 1024 ** 2) throw new Error('Source audit exceeds 5000 files or 32 MiB. Choose the application directory.');
|
|
42
|
-
|
|
43
|
-
let ast;
|
|
44
|
-
try { ast = parse(source, { sourceType: 'unambiguous', plugins: ['jsx', ...( /\.[cm]?tsx?$/.test(item.name) ? ['typescript'] : []), 'decorators-legacy'] }); }
|
|
45
|
-
catch (error) { finding('warning', 'source-syntax', file, error.loc?.line || 1, 'The audit could not parse this source file.', 'Validate it with the production compiler; custom loaders may be required.'); continue; }
|
|
46
|
-
const client = ast.program.directives.some(directive => directive.value.value === 'use client');
|
|
47
|
-
walkAst(ast.program, node => {
|
|
48
|
-
const specifier = ['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(node.type) && node.importKind !== 'type' && node.exportKind !== 'type' ? node.source?.value
|
|
49
|
-
: node.type === 'CallExpression' && (node.callee.type === 'Import' || node.callee.name === 'require') ? node.arguments[0]?.value : undefined;
|
|
50
|
-
if (typeof specifier !== 'string' || !(specifier === 'next' || specifier.startsWith('next/'))) return;
|
|
51
|
-
if (node.type === 'ImportDeclaration' && node.specifiers.length && node.specifiers.every(value => value.importKind === 'type')) return;
|
|
52
|
-
const name = frameworkImportName(specifier), available = supported.has(name);
|
|
53
|
-
report.imports.push({ file, line: node.loc.start.line, specifier, supported: available });
|
|
54
|
-
if (!available) finding('error', 'unsupported-import', file, node.loc.start.line, `No PRNext runtime adapter for ${specifier}.`, 'Replace this import with a supported public API or keep this route on Next.js.');
|
|
55
|
-
else if (client && ['headers', 'cache', 'server', 'og'].includes(name)) finding('error', 'server-import-in-client', file, node.loc.start.line, `${specifier} is imported by a client module.`, 'Move the server operation into a Server Component or Server Action.');
|
|
56
|
-
});
|
|
33
|
+
sources.push(filename);
|
|
57
34
|
}
|
|
58
35
|
}
|
|
59
36
|
try { await scan(root); }
|
|
60
37
|
catch (error) { finding('error', 'scan-incomplete', null, null, error.message, 'Resolve the file access or size limit and run the check again.'); }
|
|
38
|
+
try {
|
|
39
|
+
const graph = await auditImportGraph(root, sources);
|
|
40
|
+
report.imports = graph.imports;
|
|
41
|
+
report.findings.push(...graph.findings);
|
|
42
|
+
report.dependencies = graph.dependencies;
|
|
43
|
+
report.evidence = graph.evidence;
|
|
44
|
+
if (report.findings.some(item => ['linked-source', 'scan-incomplete'].includes(item.id))) report.evidence.state = 'unknown';
|
|
45
|
+
} catch {
|
|
46
|
+
finding('warning', 'import-graph-incomplete', null, null, 'The imported dependency graph could not be checked.', 'Run a production build and application tests before migrating.');
|
|
47
|
+
report.evidence = { state: 'unknown', scope: 'The imported dependency graph could not be checked.' };
|
|
48
|
+
}
|
|
61
49
|
let pkg = {};
|
|
62
50
|
try { pkg = JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8')); }
|
|
63
51
|
catch (error) { finding('error', 'package-json', 'package.json', null, error.message, 'Provide a valid package.json in the application directory.'); }
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import { parse } from '@babel/parser';
|
|
2
|
+
import { context as esbuildContext } from 'esbuild';
|
|
3
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
4
|
+
import { builtinModules } from 'node:module';
|
|
5
|
+
import { fileURLToPath } from 'node:url';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
import { frameworkImportName, frameworkRuntimeImportPattern } from './framework-imports.mjs';
|
|
8
|
+
import { packageManifest } from '../native/resolve.mjs';
|
|
9
|
+
|
|
10
|
+
const packageRoot = fileURLToPath(new URL('../', import.meta.url));
|
|
11
|
+
const builtins = new Set(builtinModules.flatMap(name => [name, `node:${name}`]));
|
|
12
|
+
const code = /\.(?:[cm]?[jt]s|[jt]sx)$/;
|
|
13
|
+
const slash = name => name.split(path.sep).join('/');
|
|
14
|
+
const limits = Object.freeze({ modules: 2000, edges: 12000, bytes: 32 * 1024 ** 2, depth: 32, findings: 2000 });
|
|
15
|
+
const isType = node => node.importKind === 'type' || node.exportKind === 'type';
|
|
16
|
+
const literal = node => node?.type === 'StringLiteral' ? node.value : undefined;
|
|
17
|
+
const property = node => node && (!node.computed && node.property?.name || literal(node.property));
|
|
18
|
+
|
|
19
|
+
function walk(node, visit) {
|
|
20
|
+
if (!node || typeof node !== 'object') return;
|
|
21
|
+
if (typeof node.type === 'string') visit(node);
|
|
22
|
+
for (const [key, value] of Object.entries(node)) {
|
|
23
|
+
if (['loc', 'comments', 'tokens', 'extra'].includes(key)) continue;
|
|
24
|
+
if (Array.isArray(value)) for (const child of value) walk(child, visit);
|
|
25
|
+
else if (value && typeof value === 'object') walk(value, visit);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function sourceImports(ast) {
|
|
30
|
+
const found = [], calls = new Map();
|
|
31
|
+
walk(ast.program, node => {
|
|
32
|
+
if (isType(node)) return;
|
|
33
|
+
if (['ImportDeclaration', 'ExportNamedDeclaration', 'ExportAllDeclaration'].includes(node.type) && node.source) {
|
|
34
|
+
const entries = (node.specifiers || []).filter(item => !isType(item));
|
|
35
|
+
if (node.specifiers?.length && !entries.length) return;
|
|
36
|
+
const names = entries.filter(item => item.type !== 'ImportNamespaceSpecifier').map(item => item.type === 'ImportDefaultSpecifier' ? 'default' : item.imported?.name || item.imported?.value || item.local?.name).filter(Boolean);
|
|
37
|
+
found.push({ specifier: node.source.value, line: node.loc.start.line, names, kind: 'import-statement' });
|
|
38
|
+
}
|
|
39
|
+
if (node.type === 'CallExpression' && (node.callee.type === 'Import' || node.callee.type === 'Identifier' && node.callee.name === 'require')) {
|
|
40
|
+
const specifier = literal(node.arguments[0]);
|
|
41
|
+
if (specifier !== undefined) { const item = { specifier, line: node.loc.start.line, names: [], kind: node.callee.type === 'Import' ? 'dynamic-import' : 'require-call' }; found.push(item); calls.set(node, item); }
|
|
42
|
+
else if (node.arguments.length) found.push({ specifier: null, line: node.loc.start.line, names: [], kind: 'computed-import' });
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
walk(ast.program, node => {
|
|
46
|
+
if (node.type !== 'VariableDeclarator' || node.id.type !== 'ObjectPattern' || !calls.has(node.init)) return;
|
|
47
|
+
calls.get(node.init).names = node.id.properties.filter(item => item.type === 'ObjectProperty' && !item.computed).map(item => item.key.name || item.key.value);
|
|
48
|
+
});
|
|
49
|
+
return found;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Read adapter declarations, never require an adapter or execute application code.
|
|
53
|
+
async function adapterExports(filename) {
|
|
54
|
+
const ast = parse(await readFile(filename, 'utf8'), { sourceType: 'unambiguous', plugins: ['jsx'] });
|
|
55
|
+
const names = new Set(), cjs = filename.endsWith('.cjs');
|
|
56
|
+
let complete = true;
|
|
57
|
+
walk(ast.program, node => {
|
|
58
|
+
if (node.type === 'AssignmentExpression' && node.left.type === 'MemberExpression') {
|
|
59
|
+
const left = node.left, object = left.object;
|
|
60
|
+
const moduleExports = object?.type === 'Identifier' && object.name === 'module' && property(left) === 'exports';
|
|
61
|
+
const named = object?.type === 'Identifier' && object.name === 'exports' || object?.type === 'MemberExpression' && object.object?.name === 'module' && property(object) === 'exports';
|
|
62
|
+
if (moduleExports) {
|
|
63
|
+
names.add('default');
|
|
64
|
+
if (node.right.type === 'ObjectExpression') for (const item of node.right.properties) {
|
|
65
|
+
if (item.type === 'SpreadElement' || item.computed) complete = false;
|
|
66
|
+
else names.add(item.key.name || item.key.value);
|
|
67
|
+
}
|
|
68
|
+
else if (!['Identifier', 'FunctionExpression', 'ArrowFunctionExpression', 'ClassExpression'].includes(node.right.type)) complete = false;
|
|
69
|
+
}
|
|
70
|
+
else if (named && property(left)) names.add(property(left));
|
|
71
|
+
}
|
|
72
|
+
if (node.type === 'ExportDefaultDeclaration') names.add('default');
|
|
73
|
+
if (node.type === 'ExportAllDeclaration') complete = false;
|
|
74
|
+
if (node.type === 'ExportNamedDeclaration') {
|
|
75
|
+
for (const item of node.specifiers) names.add(item.exported?.name || item.exported?.value);
|
|
76
|
+
const declaration = node.declaration;
|
|
77
|
+
if (declaration?.id?.name) names.add(declaration.id.name);
|
|
78
|
+
for (const item of declaration?.declarations || []) if (item.id.type === 'Identifier') names.add(item.id.name); else complete = false;
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
if (cjs) names.add('default');
|
|
82
|
+
return { names, complete };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function adapterFile(name) {
|
|
86
|
+
if (name === 'next-router-context' || name === 'next-app-router-context') return path.join(packageRoot, `compat/${name}.cjs`);
|
|
87
|
+
const exported = name === 'redis-cache' ? 'cache/redis' : name;
|
|
88
|
+
let target = packageManifest.exports[`./${exported}`];
|
|
89
|
+
if (target && typeof target === 'object') target = target.default;
|
|
90
|
+
return typeof target === 'string' && code.test(target) ? path.resolve(packageRoot, target) : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Follow only imported modules. Resolution reads package metadata; no module is loaded. */
|
|
94
|
+
export async function auditImportGraph(root, sourceFiles) {
|
|
95
|
+
// Resolved modules already use physical paths. Match the root to that same
|
|
96
|
+
// coordinate system (Windows DOS aliases and macOS /var links included).
|
|
97
|
+
// A missing/unreadable root retains the existing unknown/error diagnostics.
|
|
98
|
+
root = await realpath(path.resolve(root)).catch(() => path.resolve(root));
|
|
99
|
+
const findings = [], imports = [], dependencies = new Map(), seen = new Set(), physical = new Set(), adapters = new Map();
|
|
100
|
+
const queue = sourceFiles.map(filename => ({ filename, chain: [], realm: 'node', package: null }));
|
|
101
|
+
let bytes = 0, edges = 0, unknown = 0, limited = false;
|
|
102
|
+
const emitted = new Set(), resolvers = new Map();
|
|
103
|
+
const fileLabel = item => item.package ? `npm:${item.package.name}/${slash(path.relative(item.package.root, item.filename))}` : slash(path.relative(root, item.filename));
|
|
104
|
+
function finding(item, entry, id, severity, message, action) {
|
|
105
|
+
const key = `${fileLabel(item)}:${entry?.line || 0}:${id}:${entry?.specifier || ''}:${message}`;
|
|
106
|
+
if (emitted.has(key)) return;
|
|
107
|
+
if (findings.length >= limits.findings) { limited = true; return; }
|
|
108
|
+
emitted.add(key);
|
|
109
|
+
findings.push({ severity, id, file: fileLabel(item), line: entry?.line || 1, message, action, chain: item.chain });
|
|
110
|
+
}
|
|
111
|
+
async function resolve(specifier, item, kind, realm) {
|
|
112
|
+
if (!resolvers.has(realm)) {
|
|
113
|
+
let builder;
|
|
114
|
+
const context = await esbuildContext({ absWorkingDir: root, stdin: { contents: '', resolveDir: root }, bundle: true, write: false, logLevel: 'silent', platform: realm === 'browser' ? 'browser' : 'node', conditions: realm === 'rsc' ? ['react-server'] : [], plugins: [{ name: 'migration-resolver', setup(build) { builder = build; } }] });
|
|
115
|
+
resolvers.set(realm, { context, builder });
|
|
116
|
+
}
|
|
117
|
+
return resolvers.get(realm).builder.resolve(specifier, { resolveDir: path.dirname(item.filename), importer: item.filename, kind });
|
|
118
|
+
}
|
|
119
|
+
async function packageFor(filename, specifier, inherited) {
|
|
120
|
+
if (specifier.startsWith('.') || path.isAbsolute(specifier)) return inherited;
|
|
121
|
+
// Package roots are discovered upward from the resolved file, including pnpm
|
|
122
|
+
// stores and workspace symlinks. Never enumerate their sibling packages.
|
|
123
|
+
const name = specifier.startsWith('@') ? specifier.split('/').slice(0, 2).join('/') : specifier.split('/')[0];
|
|
124
|
+
let directory = path.dirname(filename);
|
|
125
|
+
for (let depth = 0; depth < 32; depth++) {
|
|
126
|
+
try {
|
|
127
|
+
const metadata = JSON.parse(await readFile(path.join(directory, 'package.json'), 'utf8'));
|
|
128
|
+
if (metadata.name === name) return { name, version: typeof metadata.version === 'string' ? metadata.version : null, root: directory };
|
|
129
|
+
} catch {}
|
|
130
|
+
const parent = path.dirname(directory); if (parent === directory) break; directory = parent;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
try {
|
|
135
|
+
for (let index = 0; index < queue.length; index++) {
|
|
136
|
+
const item = queue[index];
|
|
137
|
+
if (physical.size >= limits.modules || edges >= limits.edges || bytes >= limits.bytes) { limited = true; break; }
|
|
138
|
+
let filename, source, ast;
|
|
139
|
+
try {
|
|
140
|
+
filename = await realpath(item.filename);
|
|
141
|
+
const key = `${filename}:${item.realm}`;
|
|
142
|
+
if (seen.has(key)) continue;
|
|
143
|
+
seen.add(key);
|
|
144
|
+
const info = await stat(filename);
|
|
145
|
+
if (!info.isFile() || info.size > limits.bytes - bytes) { limited = true; break; }
|
|
146
|
+
bytes += info.size; physical.add(filename);
|
|
147
|
+
source = await readFile(filename, 'utf8');
|
|
148
|
+
ast = parse(source, { sourceType: 'unambiguous', plugins: ['jsx', ...( /\.[cm]?tsx?$/.test(filename) ? ['typescript'] : []), 'decorators-legacy'] });
|
|
149
|
+
} catch {
|
|
150
|
+
unknown++;
|
|
151
|
+
finding(item, null, 'source-syntax', 'warning', 'This imported source could not be read or parsed.', 'Run a production build with the application loaders to validate this module.');
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
item.filename = filename;
|
|
155
|
+
const client = ast.program.directives.some(value => value.value.value === 'use client');
|
|
156
|
+
const action = ast.program.directives.some(value => value.value.value === 'use server');
|
|
157
|
+
if (/\.[cm]ts$/i.test(filename)) {
|
|
158
|
+
let directive = ast.program.directives.find(value => ['use client', 'use server'].includes(value.value.value));
|
|
159
|
+
walk(ast.program, node => { if (!directive && node.type === 'Directive' && node.value.value === 'use server') directive = node; });
|
|
160
|
+
if (directive) finding(item, { line: directive.loc.start.line }, 'unsupported-boundary-extension', 'error',
|
|
161
|
+
'React server/client directives are not supported in .mts or .cts modules.', 'Rename this boundary module to .ts or .tsx, then run the audit again.');
|
|
162
|
+
}
|
|
163
|
+
// App rendering entries execute with react-server conditions; Route
|
|
164
|
+
// Handlers and ordinary Node helpers do not. Keep the Node oracle too.
|
|
165
|
+
const relative = slash(path.relative(root, filename));
|
|
166
|
+
if (item.realm === 'node' && !client && !item.package && /^(?:src\/)?app\/(?:[^/]+\/)*(?:page|layout|template|loading|error|global-error|not-found|default)\.[cm]?[jt]sx?$/.test(relative)) {
|
|
167
|
+
queue.push({ ...item, realm: 'rsc' });
|
|
168
|
+
}
|
|
169
|
+
// The browser compiler replaces a module-level Server Action with client
|
|
170
|
+
// references; its implementation and imports execute in the RSC graph.
|
|
171
|
+
// Continue auditing that graph instead of either reporting client-only
|
|
172
|
+
// violations or skipping server dependencies. A function-body directive
|
|
173
|
+
// is not an importable boundary, and 'use client' still takes precedence.
|
|
174
|
+
const realm = client ? 'browser' : action && item.realm === 'browser' ? 'rsc' : item.realm;
|
|
175
|
+
for (const entry of sourceImports(ast)) {
|
|
176
|
+
if (++edges > limits.edges) { limited = true; break; }
|
|
177
|
+
if (!entry.specifier) { unknown++; finding(item, entry, 'computed-import', 'info', 'A computed import needs runtime validation.', 'Exercise this code path in the production build and application tests.'); continue; }
|
|
178
|
+
// These are compiler markers, not packages to execute or traverse.
|
|
179
|
+
if (entry.specifier === 'server-only' || entry.specifier === 'client-only') {
|
|
180
|
+
if (entry.specifier === 'server-only' && realm === 'browser') finding(item, entry, 'server-only-in-client', 'error',
|
|
181
|
+
'A server-only module is reachable from a Client Component.', 'Move this import behind a Server Component or module-level Server Action boundary.');
|
|
182
|
+
if (entry.specifier === 'client-only' && realm === 'rsc') finding(item, entry, 'client-only-in-server', 'error',
|
|
183
|
+
'A client-only module is reachable from a Server Component.', "Add a 'use client' boundary around this browser code.");
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
if (frameworkRuntimeImportPattern.test(entry.specifier)) {
|
|
187
|
+
const name = frameworkImportName(entry.specifier), target = adapterFile(name), available = !!target;
|
|
188
|
+
imports.push({ file: fileLabel(item), line: entry.line, specifier: entry.specifier, supported: available, exports: entry.names, chain: item.chain });
|
|
189
|
+
if (!available) finding(item, entry, 'unsupported-import', 'error', `No PRNext runtime adapter for ${entry.specifier}.`, 'Replace this import with a supported public API or keep this route on Next.js.');
|
|
190
|
+
else {
|
|
191
|
+
if (!adapters.has(target)) adapters.set(target, await adapterExports(target));
|
|
192
|
+
const adapter = adapters.get(target);
|
|
193
|
+
const compilerOwned = ['font/google', 'font/local'].includes(name);
|
|
194
|
+
if (compilerOwned) {
|
|
195
|
+
unknown++;
|
|
196
|
+
finding(item, entry, 'compiler-import', 'info', `${entry.specifier} is resolved by the production font transform.`, 'Run a production build to validate this font name and its options.');
|
|
197
|
+
}
|
|
198
|
+
for (const name of entry.names) if (!adapter.names.has(name) && !compilerOwned) {
|
|
199
|
+
if (adapter.complete) finding(item, entry, 'unsupported-export', 'error', `${entry.specifier} does not provide the runtime export "${name}" in this PRNext release.`, 'Use an export provided by this release or retain the original framework for this code path.');
|
|
200
|
+
else { unknown++; finding(item, entry, 'unknown-export', 'warning', `The runtime export "${name}" from ${entry.specifier} could not be checked statically.`, 'Validate this import with a production build.'); }
|
|
201
|
+
}
|
|
202
|
+
if (realm === 'browser' && ['headers', 'cache', 'server', 'og', 'redis-cache'].includes(name)) finding(item, entry, 'server-import-in-client', 'error', `${entry.specifier} is imported by a client module.`, 'Move the server operation into a Server Component or Server Action.');
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
let browserBuiltin;
|
|
207
|
+
if (builtins.has(entry.specifier)) {
|
|
208
|
+
if (realm !== 'browser') continue;
|
|
209
|
+
// The App browser compiler does not inject Node polyfills. Resolve
|
|
210
|
+
// first so npm shims and package browser mappings remain valid.
|
|
211
|
+
browserBuiltin = await resolve(entry.specifier, item, entry.kind, realm);
|
|
212
|
+
// esbuild returns browser:false as the unchanged specifier with no
|
|
213
|
+
// file namespace, rather than a physical module to traverse.
|
|
214
|
+
if (!browserBuiltin.errors.length && !browserBuiltin.external && browserBuiltin.namespace === '' && browserBuiltin.path === entry.specifier) continue;
|
|
215
|
+
if (browserBuiltin.errors.length || browserBuiltin.external || !browserBuiltin.path) {
|
|
216
|
+
finding(item, entry, 'node-builtin-in-client', 'error', `${entry.specifier} is a Node builtin imported by a client module without a browser implementation.`, 'Move this operation into a Server Component or Server Action, or configure and validate a browser implementation.');
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
if (/^(?:react|react-dom|react-server-dom-webpack)(?:\/|$)/.test(entry.specifier)) continue;
|
|
221
|
+
// CSS, images and native addons are owned by loaders/native packaging.
|
|
222
|
+
if (/\.(?:css|s[ac]ss|less|png|jpe?g|gif|webp|avif|svg|ico|woff2?|ttf|json)$/.test(entry.specifier)) continue;
|
|
223
|
+
if (item.chain.length >= limits.depth) { unknown++; limited = true; continue; }
|
|
224
|
+
const resolution = browserBuiltin || await resolve(entry.specifier, item, entry.kind, realm);
|
|
225
|
+
if (resolution.errors.length || resolution.external || !resolution.path) {
|
|
226
|
+
unknown++;
|
|
227
|
+
finding(item, entry, 'unresolved-import', 'warning', `The imported module "${entry.specifier}" could not be resolved for this audit.`, 'Install the application dependencies and run a production build; custom aliases and optional imports may need application-specific validation.');
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
if (!code.test(resolution.path)) { unknown++; finding(item, entry, 'loader-import', 'info', `The imported module "${entry.specifier}" requires loader or native validation.`, 'Validate it with a production build and deployed application tests.'); continue; }
|
|
231
|
+
const dependency = await packageFor(resolution.path, entry.specifier, item.package);
|
|
232
|
+
if (dependency) {
|
|
233
|
+
const key = `${dependency.name}@${dependency.version || 'unknown'}`;
|
|
234
|
+
if (!dependencies.has(key)) dependencies.set(key, { name: dependency.name, version: dependency.version, state: 'checked', importedBy: [] });
|
|
235
|
+
const record = dependencies.get(key);
|
|
236
|
+
if (record.importedBy.length < 16 && !record.importedBy.some(value => value.file === fileLabel(item) && value.line === entry.line)) record.importedBy.push({ file: fileLabel(item), line: entry.line, specifier: entry.specifier });
|
|
237
|
+
}
|
|
238
|
+
queue.push({ filename: resolution.path, realm, package: dependency, chain: [...item.chain, { file: fileLabel(item), line: entry.line, specifier: entry.specifier }] });
|
|
239
|
+
}
|
|
240
|
+
if (limited && edges >= limits.edges) break;
|
|
241
|
+
}
|
|
242
|
+
} finally { await Promise.all([...resolvers.values()].map(value => value.context.dispose())); }
|
|
243
|
+
if (limited) findings.push({ severity: 'warning', id: 'import-graph-limit', file: null, line: null, message: 'The imported dependency graph exceeded an audit limit.', action: 'Run a production build and application tests; this audit does not cover the remaining modules.' });
|
|
244
|
+
return { imports, findings, dependencies: [...dependencies.values()].sort((a, b) => a.name.localeCompare(b.name)), evidence: { state: unknown || limited ? 'unknown' : 'checked', modules: physical.size, edges, bytes, limits, unknown, limited, scope: 'Static imports and selected Node, browser and React Server Component package conditions. A checked module is not a compatibility certification; custom loaders, aliases, runtime branches and behavior still require a production build and application tests.' } };
|
|
245
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { pathToFileURL } from 'node:url';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
let browser, browserServer, activeContext, closing = false;
|
|
6
|
+
async function close() {
|
|
7
|
+
if (closing) return;
|
|
8
|
+
closing = true;
|
|
9
|
+
await activeContext?.close().catch(() => {});
|
|
10
|
+
await browser?.close().catch(() => {});
|
|
11
|
+
await browserServer?.close().catch(() => {});
|
|
12
|
+
if (process.connected) process.send({ type: 'browser-closed' });
|
|
13
|
+
}
|
|
14
|
+
process.on('disconnect', () => { void close().finally(() => process.exit(1)); });
|
|
15
|
+
process.on('SIGTERM', () => { void close().finally(() => process.exit(1)); });
|
|
16
|
+
process.on('message', async message => {
|
|
17
|
+
if (message?.type === 'close') { await close(); process.exit(0); }
|
|
18
|
+
if (message?.type !== 'run') return;
|
|
19
|
+
const results = [];
|
|
20
|
+
let report;
|
|
21
|
+
const unknown = reason => ({ ok: false, state: 'unknown', reason, results });
|
|
22
|
+
try {
|
|
23
|
+
const require = createRequire(path.join(message.root, 'package.json'));
|
|
24
|
+
let playwright;
|
|
25
|
+
for (const name of ['playwright', '@playwright/test']) {
|
|
26
|
+
try { playwright = await import(pathToFileURL(require.resolve(name)).href); break; } catch {}
|
|
27
|
+
}
|
|
28
|
+
if (!playwright?.chromium && !playwright?.default?.chromium) { report = unknown('playwright-unavailable'); return; }
|
|
29
|
+
let scenarios, importTimer, expiredImport = false;
|
|
30
|
+
try { scenarios = (await Promise.race([
|
|
31
|
+
import(pathToFileURL(message.filename).href),
|
|
32
|
+
new Promise((_, reject) => { importTimer = setTimeout(() => { expiredImport = true; reject(new Error('timeout')); }, Math.min(10000, Math.max(1000, message.timeout))); }),
|
|
33
|
+
])).default; }
|
|
34
|
+
catch { report = unknown(expiredImport ? 'scenario-module-timeout' : 'scenario-module-failed'); return; }
|
|
35
|
+
finally { clearTimeout(importTimer); }
|
|
36
|
+
if (!Array.isArray(scenarios) || !scenarios.length || scenarios.length > 20 || scenarios.some(item => !item || typeof item.run !== 'function')) { report = unknown('invalid-scenarios'); return; }
|
|
37
|
+
try {
|
|
38
|
+
const chromium = playwright.chromium || playwright.default.chromium;
|
|
39
|
+
browserServer = await chromium.launchServer({ headless: true, timeout: 10000 });
|
|
40
|
+
// The parent can stop the specifically owned browser even when a trusted
|
|
41
|
+
// callback blocks this worker's event loop with synchronous JavaScript.
|
|
42
|
+
if (process.connected) process.send({ type: 'browser-started', pid: browserServer.process().pid });
|
|
43
|
+
browser = await chromium.connect(browserServer.wsEndpoint(), { timeout: 10000 });
|
|
44
|
+
}
|
|
45
|
+
catch { report = unknown('browser-unavailable'); return; }
|
|
46
|
+
for (let index = 0; index < scenarios.length; index++) {
|
|
47
|
+
for (const target of ['reference', 'candidate']) {
|
|
48
|
+
let timer, expired = false;
|
|
49
|
+
try {
|
|
50
|
+
activeContext = await browser.newContext({ serviceWorkers: 'block' });
|
|
51
|
+
const page = await activeContext.newPage();
|
|
52
|
+
page.setDefaultTimeout(message.timeout);
|
|
53
|
+
page.setDefaultNavigationTimeout(message.timeout);
|
|
54
|
+
await Promise.race([
|
|
55
|
+
Promise.resolve().then(() => scenarios[index].run({ page, baseURL: target === 'reference' ? message.reference : message.candidate })),
|
|
56
|
+
new Promise((_, reject) => { timer = setTimeout(() => { expired = true; reject(new Error('timeout')); }, message.timeout); }),
|
|
57
|
+
]);
|
|
58
|
+
results.push({ scenario: index + 1, target, status: 'passed' });
|
|
59
|
+
} catch { results.push({ scenario: index + 1, target, status: 'failed', reason: expired ? 'timeout' : 'assertion-or-runtime-failure' }); }
|
|
60
|
+
finally { clearTimeout(timer); await activeContext?.close().catch(() => {}); activeContext = undefined; }
|
|
61
|
+
// An expired callback may continue running its own JavaScript. Stop this
|
|
62
|
+
// worker after closing its browser instead of starting another target.
|
|
63
|
+
if (expired) { report = unknown('worker-failed'); return; }
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
report = { ok: results.every(item => item.status === 'passed'), state: 'checked', results };
|
|
67
|
+
} catch { report = unknown('worker-failed'); }
|
|
68
|
+
finally {
|
|
69
|
+
await close();
|
|
70
|
+
if (process.connected) process.send({ type: 'result', report: report || unknown('worker-failed') });
|
|
71
|
+
else process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { execFile, fork } from 'node:child_process';
|
|
2
|
+
import { readFile, realpath, stat } from 'node:fs/promises';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
|
|
6
|
+
const runner = fileURLToPath(new URL('./migration-scenarios-runner.mjs', import.meta.url));
|
|
7
|
+
const scope = 'Application-provided browser scenarios run independently against each server in isolated contexts. Passing both servers does not establish full application equivalence. Application code is explicitly executed and may submit forms or change test data. Browser messages, request data and raw errors are excluded from this report.';
|
|
8
|
+
function origin(value) {
|
|
9
|
+
const url = new URL(value);
|
|
10
|
+
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.pathname !== '/' || url.search || url.hash) throw new Error('Scenario servers must be HTTP(S) origins without credentials, paths, or queries.');
|
|
11
|
+
return url.origin;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The explicit scenario option is the only path that loads application test code. */
|
|
15
|
+
export async function runMigrationScenarios({ directory, against, candidate, scenarios, timeout = 10000 }) {
|
|
16
|
+
const root = await realpath(path.resolve(directory));
|
|
17
|
+
const reference = origin(against), current = origin(candidate);
|
|
18
|
+
if (typeof scenarios !== 'string' || !scenarios || !/\.[cm]?js$/.test(scenarios)) throw new Error('Provide an application browser scenario module ending in .mjs, .cjs or .js.');
|
|
19
|
+
const filename = await realpath(path.resolve(root, scenarios));
|
|
20
|
+
const relative = path.relative(root, filename);
|
|
21
|
+
if (!relative || relative.startsWith(`..${path.sep}`) || relative === '..' || path.isAbsolute(relative)) throw new Error('The browser scenario module must be inside the application directory.');
|
|
22
|
+
if (!(await stat(filename)).isFile()) throw new Error('The browser scenario module must be a regular file.');
|
|
23
|
+
if (!Number.isInteger(timeout) || timeout < 100 || timeout > 60000) throw new Error('Scenario timeout must be 100–60000 milliseconds.');
|
|
24
|
+
// Require an application manifest, without loading it as executable code.
|
|
25
|
+
JSON.parse(await readFile(path.join(root, 'package.json'), 'utf8'));
|
|
26
|
+
return new Promise(resolve => {
|
|
27
|
+
const child = fork(runner, [], { cwd: root, execArgv: [], stdio: ['ignore', 'ignore', 'ignore', 'ipc'], windowsHide: true });
|
|
28
|
+
let settled = false, report, timer, killTimer, browserPid, termination = Promise.resolve();
|
|
29
|
+
const incomplete = reason => ({ ok: false, state: 'unknown', reason, results: [], scope });
|
|
30
|
+
const finish = value => {
|
|
31
|
+
if (settled) return;
|
|
32
|
+
settled = true; clearTimeout(timer); clearTimeout(killTimer);
|
|
33
|
+
child.removeAllListeners('message');
|
|
34
|
+
resolve({ ...value, scope });
|
|
35
|
+
};
|
|
36
|
+
// The worker has 20 scenarios maximum, two targets each, plus a bounded
|
|
37
|
+
// startup/teardown budget. A hung module import cannot hold the CLI open.
|
|
38
|
+
const terminate = () => {
|
|
39
|
+
if (child.exitCode !== null || child.signalCode !== null || !child.pid) return;
|
|
40
|
+
// Kill only this worker's descendants on Windows. A synchronous user test
|
|
41
|
+
// cannot process SIGTERM or close its browser itself.
|
|
42
|
+
if (process.platform === 'win32') termination = new Promise(done => execFile('taskkill', ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true }, () => { if (child.exitCode === null) child.kill(); done(); }));
|
|
43
|
+
else {
|
|
44
|
+
// BrowserServer owns this process, and Chromium closes its subprocesses
|
|
45
|
+
// when that browser process exits. No shared browser is ever connected.
|
|
46
|
+
if (browserPid) { try { process.kill(browserPid, 'SIGKILL'); } catch {} }
|
|
47
|
+
child.kill('SIGTERM'); killTimer = setTimeout(() => child.kill('SIGKILL'), 1000);
|
|
48
|
+
}
|
|
49
|
+
};
|
|
50
|
+
const budget = Math.min(300000, 20000 + timeout * 40);
|
|
51
|
+
timer = setTimeout(() => { report = incomplete('worker-timeout'); terminate(); }, budget);
|
|
52
|
+
child.on('message', message => {
|
|
53
|
+
if (message?.type === 'browser-started' && Number.isInteger(message.pid) && message.pid > 0 && message.pid !== process.pid && message.pid !== child.pid) { browserPid = message.pid; return; }
|
|
54
|
+
if (message?.type === 'browser-closed') { browserPid = undefined; return; }
|
|
55
|
+
if (message?.type !== 'result' || !message.report || report) return;
|
|
56
|
+
// Never forward arbitrary fields from application code or a compromised
|
|
57
|
+
// IPC message: the public report has a fixed, deliberately small schema.
|
|
58
|
+
const input = message.report;
|
|
59
|
+
if (!Array.isArray(input.results) || input.results.length > 40 || input.results.some(item => !item || !Number.isInteger(item.scenario) || item.scenario < 1 || item.scenario > 20 || !['reference', 'candidate'].includes(item.target) || !['passed', 'failed'].includes(item.status))) { report = incomplete('invalid-result'); terminate(); return; }
|
|
60
|
+
const results = input.results.map(item => ({ scenario: Number.isInteger(item.scenario) ? item.scenario : 0, target: item.target === 'reference' ? 'reference' : 'candidate', status: item.status === 'passed' ? 'passed' : 'failed', ...(item.reason === 'timeout' ? { reason: 'timeout' } : item.status !== 'passed' ? { reason: 'assertion-or-runtime-failure' } : {}) }));
|
|
61
|
+
const allowed = new Set(['playwright-unavailable', 'invalid-scenarios', 'scenario-module-failed', 'scenario-module-timeout', 'browser-unavailable', 'worker-failed']);
|
|
62
|
+
report = { ok: input.ok === true && results.length > 0 && results.every(item => item.status === 'passed'), state: input.state === 'checked' ? 'checked' : 'unknown', ...(allowed.has(input.reason) ? { reason: input.reason } : {}), results };
|
|
63
|
+
child.send({ type: 'close' }, () => {});
|
|
64
|
+
// A module can leave its own timers running. Once the owned browser has
|
|
65
|
+
// closed, exit the worker rather than waiting for application handles.
|
|
66
|
+
clearTimeout(timer); timer = setTimeout(terminate, 5000);
|
|
67
|
+
});
|
|
68
|
+
child.once('error', () => finish(incomplete('worker-failed')));
|
|
69
|
+
child.once('close', () => { void termination.then(() => finish(report || incomplete('worker-failed'))); });
|
|
70
|
+
child.send({ type: 'run', root, filename, reference, candidate: current, timeout }, error => { if (error) { report = incomplete('worker-failed'); terminate(); } });
|
|
71
|
+
});
|
|
72
|
+
}
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
import { readdir, readFile, realpath } from 'node:fs/promises';
|
|
2
|
-
import { createRequire } from 'node:module';
|
|
2
|
+
import { builtinModules, createRequire } from 'node:module';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
+
import { analyzeSource } from './source-analysis.mjs';
|
|
5
|
+
|
|
6
|
+
const builtins = new Set([...builtinModules, ...builtinModules.map(name => 'node:' + name)]);
|
|
4
7
|
|
|
5
8
|
/** Inspect only packages reached through imports and declared runtime dependencies. */
|
|
6
9
|
export function createNativeDiscovery({ projectRoot, isClientSource }) {
|
|
@@ -9,6 +12,27 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
|
|
|
9
12
|
const packageFiles = new Map();
|
|
10
13
|
const dependencyRoots = new Map();
|
|
11
14
|
const clientBoundaries = new Map();
|
|
15
|
+
const sourceImports = new Map(), entryNative = new Map(), declaredNative = new Map();
|
|
16
|
+
const resolvers = new Map();
|
|
17
|
+
let sourceBytes = 0;
|
|
18
|
+
const isApplication = directory => {
|
|
19
|
+
const relative = path.relative(directory, projectRoot);
|
|
20
|
+
return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function importsFor(file) {
|
|
24
|
+
if (!sourceImports.has(file)) sourceImports.set(file, (async () => {
|
|
25
|
+
if (!/\.(?:[cm]?[jt]s|jsx|tsx)$/.test(file) || /\.d\.[cm]?ts$/.test(file)) return { imports: [], computedRequire: false };
|
|
26
|
+
const source = await readFile(file, 'utf8');
|
|
27
|
+
const size = Buffer.byteLength(source);
|
|
28
|
+
if (size > 8 * 1024 ** 2 || (sourceBytes += size) > 64 * 1024 ** 2) throw new Error('Native npm import analysis exceeds its 8 MiB file / 64 MiB source budget. Separate the native loader into a server-only package.');
|
|
29
|
+
// The compiler accepts JSX in JavaScript npm entries, including .js.
|
|
30
|
+
const { imports, computedRequire } = analyzeSource(source, /\.[cm]?js$/.test(file) ? file + '.jsx' : file);
|
|
31
|
+
// Keep edges, not the AST, so the shared parser can bound its retained cache.
|
|
32
|
+
return { imports, computedRequire };
|
|
33
|
+
})());
|
|
34
|
+
return sourceImports.get(file);
|
|
35
|
+
}
|
|
12
36
|
|
|
13
37
|
function readMetadata(directory) {
|
|
14
38
|
if (!metadata.has(directory)) metadata.set(directory, readFile(path.join(directory, 'package.json'), 'utf8').then(JSON.parse).catch(error => {
|
|
@@ -53,7 +77,7 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
|
|
|
53
77
|
else if (entry.isFile() || entry.isSymbolicLink()) {
|
|
54
78
|
if (entry.name.endsWith('.node')) result.native = true;
|
|
55
79
|
if (/\.(?:css|scss|sass)$/i.test(entry.name)) result.css = true;
|
|
56
|
-
if (/\.(?:[cm]?
|
|
80
|
+
if (/\.(?:[cm]?[jt]s|jsx|tsx)$/.test(entry.name) && !/\.d\.[cm]?ts$/.test(entry.name)) result.sources.push(file);
|
|
57
81
|
}
|
|
58
82
|
}
|
|
59
83
|
}
|
|
@@ -99,6 +123,38 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
|
|
|
99
123
|
return false;
|
|
100
124
|
}
|
|
101
125
|
|
|
126
|
+
function declaredNativeFor(owner) {
|
|
127
|
+
if (!declaredNative.has(owner.directory)) declaredNative.set(owner.directory, nativeReachable(owner));
|
|
128
|
+
return declaredNative.get(owner.directory);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function nativeFromEntry(file, mode) {
|
|
132
|
+
const key = mode + '\0' + file;
|
|
133
|
+
if (!entryNative.has(key)) entryNative.set(key, (async () => {
|
|
134
|
+
const pending = [file], visited = new Set();
|
|
135
|
+
while (pending.length) {
|
|
136
|
+
const current = pending.pop();
|
|
137
|
+
if (visited.has(current)) continue;
|
|
138
|
+
visited.add(current);
|
|
139
|
+
if (visited.size > 4096) throw new Error('Native npm import analysis exceeds 4096 reached modules. Separate the native loader into a server-only package.');
|
|
140
|
+
const owner = await packageFor(current);
|
|
141
|
+
if (!owner || isApplication(owner.directory)) continue;
|
|
142
|
+
if ((await filesFor(owner.directory)).native) return true;
|
|
143
|
+
const source = await importsFor(current);
|
|
144
|
+
// A runtime loader may compute a declared package name. Preserve its
|
|
145
|
+
// original npm boundary when that unknown name can reach an addon.
|
|
146
|
+
if (source.computedRequire && await declaredNativeFor(owner)) return true;
|
|
147
|
+
for (const dependency of source.imports) {
|
|
148
|
+
if (builtins.has(dependency.path)) continue;
|
|
149
|
+
const resolved = await resolvers.get(mode)(dependency.path, current, dependency.kind);
|
|
150
|
+
if (!resolved.errors?.length && !resolved.external && resolved.namespace === 'file') pending.push(resolved.path);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return false;
|
|
154
|
+
})());
|
|
155
|
+
return entryNative.get(key);
|
|
156
|
+
}
|
|
157
|
+
|
|
102
158
|
function hasClientBoundary(directory, sources) {
|
|
103
159
|
if (!clientBoundaries.has(directory)) clientBoundaries.set(directory, (async () => {
|
|
104
160
|
for (const file of sources) {
|
|
@@ -116,13 +172,17 @@ export function createNativeDiscovery({ projectRoot, isClientSource }) {
|
|
|
116
172
|
}
|
|
117
173
|
|
|
118
174
|
return {
|
|
175
|
+
setResolver(mode, resolve) { resolvers.set(mode, resolve); },
|
|
119
176
|
canExternalizeDirectory,
|
|
120
177
|
async externalOwner(file, mode) {
|
|
121
178
|
const owner = await packageFor(file);
|
|
122
179
|
if (!owner) return;
|
|
123
|
-
|
|
124
|
-
if (
|
|
125
|
-
|
|
180
|
+
if (isApplication(owner.directory)) return;
|
|
181
|
+
if (!await declaredNativeFor(owner)) return;
|
|
182
|
+
// A package can declare build tools with native dependencies that its
|
|
183
|
+
// runtime entry never imports (for example an i18n extractor). Follow
|
|
184
|
+
// that entry's real compiler resolution before externalizing its parent.
|
|
185
|
+
if (resolvers.has(mode) && !await nativeFromEntry(file, mode)) return;
|
|
126
186
|
// Keep CSS extraction and Server Component client-reference transforms in
|
|
127
187
|
// the compiler. A native dependency imported by this package is examined
|
|
128
188
|
// separately when its own entry is resolved.
|
|
@@ -66,6 +66,8 @@ export function createNativePackages(projectRoot, { isClientSource }) {
|
|
|
66
66
|
return {
|
|
67
67
|
name: `prnext-native-packages-${mode}`,
|
|
68
68
|
setup(esbuild) {
|
|
69
|
+
discovery.setResolver(mode, (name, importer, kind) => esbuild.resolve(name, { importer, resolveDir: path.dirname(importer), kind,
|
|
70
|
+
pluginData: { prnextNativeResolving: true, prnextNpmResolving: true } }));
|
|
69
71
|
esbuild.onResolve({ filter: /\.node$/ }, async args => {
|
|
70
72
|
if (args.pluginData?.prnextNativeResolving) return;
|
|
71
73
|
if (mode === 'browser') return { errors: [{ text: `Native Node addon ${args.path} cannot run in a Client Component (${args.importer}). Keep the native dependency in a Server Component or route handler.` }] };
|
package/build/npm-packages.mjs
CHANGED
|
@@ -33,6 +33,7 @@ export function npmPackagesPlugin({ pages = false, projectRoot, transpilePackage
|
|
|
33
33
|
return { name: 'prnext-npm-packages', setup(build) {
|
|
34
34
|
const resolving = { prnextNpmResolving: true, prnextNativeResolving: true };
|
|
35
35
|
const resolve = (name, importer, kind) => build.resolve(name, { importer, resolveDir: path.dirname(importer), kind, pluginData: resolving });
|
|
36
|
+
native.setResolver('ssr', resolve);
|
|
36
37
|
function classify(file) {
|
|
37
38
|
if (!decisions.has(file)) decisions.set(file, (async () => {
|
|
38
39
|
const queue = [file], visited = new Set();
|
|
@@ -85,8 +85,8 @@ export function isolatedPrerender(root, stage, manifest, options = {}, dependenc
|
|
|
85
85
|
/** Retain production graphs during local rebuilds; no HTTP listener/daemon. */
|
|
86
86
|
export async function watchProduction(root,options={}){
|
|
87
87
|
let worker,watcher,stopping=false,timer,busy=false,queued=false;
|
|
88
|
-
const changed=new Set(),filter=createDevChangeFilter(root);
|
|
89
88
|
let output='.prnext';
|
|
89
|
+
const changed=new Set(),filter=createDevChangeFilter(root,{directoryEventsAreRecursive:process.platform==='darwin',ignore:relative=>relative===output||relative.startsWith(output+'/')});
|
|
90
90
|
const stopWorker=async()=>{
|
|
91
91
|
const old=worker;worker=undefined;if(!old||old.exitCode!==null||old.signalCode)return;
|
|
92
92
|
await new Promise(resolve=>{const timer=setTimeout(()=>old.kill(),5000);old.once('exit',()=>{clearTimeout(timer);resolve();});old.disconnect();});
|