@thomas.f/prnext 0.1.18-alpha → 0.1.20-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.
Files changed (162) hide show
  1. package/README.md +2 -0
  2. package/build/actions.mjs +26 -14
  3. package/build/app.mjs +90 -101
  4. package/build/build-lock.mjs +2 -71
  5. package/build/build-retention.mjs +119 -0
  6. package/build/bundle-runtime.mjs +9 -8
  7. package/build/cache-command.mjs +31 -0
  8. package/build/check.mjs +41 -3
  9. package/build/compilation-cache.mjs +32 -12
  10. package/build/compilation-storage.mjs +190 -0
  11. package/build/compiler.mjs +8 -7
  12. package/build/config.mjs +25 -2
  13. package/build/create.mjs +1 -1
  14. package/build/css-modules.mjs +4 -0
  15. package/build/dev-action-graph.mjs +21 -0
  16. package/build/dev-css-assets.mjs +173 -0
  17. package/build/dev-css-update.mjs +14 -12
  18. package/build/dev-lazy-build.mjs +77 -0
  19. package/build/dynamic.mjs +13 -6
  20. package/build/font-cache.mjs +9 -6
  21. package/build/font-files.mjs +1 -1
  22. package/build/incremental.mjs +112 -18
  23. package/build/index.mjs +61 -15
  24. package/build/lazy-dynamic-imports.mjs +137 -0
  25. package/build/migrate.mjs +4 -4
  26. package/build/migration-audit.mjs +19 -31
  27. package/build/migration-imports.mjs +245 -0
  28. package/build/migration-scenarios-runner.mjs +73 -0
  29. package/build/migration-scenarios.mjs +72 -0
  30. package/build/native-discovery.mjs +65 -5
  31. package/build/native-packages.mjs +2 -0
  32. package/build/native-ppr.mjs +32 -0
  33. package/build/npm-packages.mjs +1 -0
  34. package/build/og.mjs +121 -1
  35. package/build/parallel-plan.mjs +55 -0
  36. package/build/persistent-cache.mjs +29 -24
  37. package/build/production-builder.mjs +1 -1
  38. package/build/production-check.mjs +34 -0
  39. package/build/react-compiler-report.mjs +31 -8
  40. package/build/react-compiler.mjs +101 -10
  41. package/build/server-config.mjs +35 -0
  42. package/build/source-analysis.mjs +54 -4
  43. package/build/standalone.mjs +21 -3
  44. package/build/static-adapter.mjs +7 -3
  45. package/build/transform.mjs +38 -6
  46. package/build/typescript-dev.mjs +3 -1
  47. package/build/webpack-graph.mjs +13 -3
  48. package/cli.mjs +24 -6
  49. package/compat/app-dynamic.cjs +12 -1
  50. package/compat/app-layout-context.cjs +7 -3
  51. package/compat/cache-handlers.cjs +3 -1
  52. package/compat/flight-delta.cjs +50 -13
  53. package/compat/index.d.ts +24 -1
  54. package/compat/link.cjs +2 -2
  55. package/compat/navigation-authorization.cjs +8 -1
  56. package/docs/ai/compatibility.md +187 -7
  57. package/docs/ai/deployment.md +141 -0
  58. package/docs/ai/quickstart.md +5 -0
  59. package/docs/ai/routing.md +11 -0
  60. package/docs/ai/troubleshooting.md +79 -0
  61. package/package.json +26 -11
  62. package/release/fontkit.mjs +31 -0
  63. package/release/verify.mjs +3 -0
  64. package/runtime/api.mjs +5 -2
  65. package/runtime/app-client.mjs +254 -53
  66. package/runtime/app-partial.mjs +2 -10
  67. package/runtime/app-prefetch.mjs +2 -2
  68. package/runtime/app-render.mjs +11 -7
  69. package/runtime/app-stream-channel.mjs +3 -2
  70. package/runtime/app-styles.mjs +22 -4
  71. package/runtime/atomic-file.mjs +27 -0
  72. package/runtime/cache-components-rsc.mjs +3 -1
  73. package/runtime/compile-cache-retention.mjs +53 -0
  74. package/runtime/compile-cache.mjs +28 -1
  75. package/runtime/dev-client.mjs +13 -5
  76. package/runtime/dev-file-changes.mjs +123 -8
  77. package/runtime/dev-gateway.mjs +3 -2
  78. package/runtime/dev-lazy-imports.mjs +121 -0
  79. package/runtime/dev-lazy-staging.mjs +14 -0
  80. package/runtime/dev-route-selection.mjs +64 -0
  81. package/runtime/dev-runtime-pool.mjs +3 -2
  82. package/runtime/dev-runtime-spare.mjs +1 -1
  83. package/runtime/dev-session-retention.mjs +154 -0
  84. package/runtime/dev-style-retention.mjs +15 -0
  85. package/runtime/development.mjs +86 -26
  86. package/runtime/env.mjs +9 -0
  87. package/runtime/flight-delta.mjs +59 -9
  88. package/runtime/fs.mjs +15 -8
  89. package/runtime/lazy-import-client.mjs +38 -0
  90. package/runtime/native-ppr.mjs +35 -0
  91. package/runtime/og/NOTICE.txt +1 -1
  92. package/runtime/og/index.node.mjs +76 -31216
  93. package/runtime/og/upstream.json +115 -0
  94. package/runtime/persistent/client.mjs +4 -2
  95. package/runtime/persistent/daemon.mjs +3 -11
  96. package/runtime/persistent/health.mjs +58 -0
  97. package/runtime/project-lock.mjs +71 -0
  98. package/runtime/request-transport.mjs +112 -32
  99. package/runtime/storage-path.mjs +36 -0
  100. package/runtime/trace.mjs +44 -4
  101. package/runtime/worker.mjs +9 -4
  102. package/vendor/fontkit/NOTICE.txt +5 -0
  103. package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
  104. package/vendor/fontkit/index.mjs +3 -0
  105. package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
  106. package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
  107. package/vendor/fontkit/licenses/brotli/readme.md +61 -0
  108. package/vendor/fontkit/licenses/clone/LICENSE +18 -0
  109. package/vendor/fontkit/licenses/dfa/README.md +83 -0
  110. package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
  111. package/vendor/fontkit/licenses/fontkit/README.md +266 -0
  112. package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
  113. package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
  114. package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
  115. package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
  116. package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
  117. package/vendor/fontkit/upstream.json +215 -0
  118. package/vendor/nft/LICENSE +7 -0
  119. package/vendor/nft/README.md +16 -0
  120. package/vendor/nft/out/analyze.d.ts +8 -0
  121. package/vendor/nft/out/analyze.js +1216 -0
  122. package/vendor/nft/out/analyze.js.map +1 -0
  123. package/vendor/nft/out/cli.d.ts +2 -0
  124. package/vendor/nft/out/cli.js +94 -0
  125. package/vendor/nft/out/cli.js.map +1 -0
  126. package/vendor/nft/out/fs.d.ts +22 -0
  127. package/vendor/nft/out/fs.js +106 -0
  128. package/vendor/nft/out/fs.js.map +1 -0
  129. package/vendor/nft/out/index.d.ts +4 -0
  130. package/vendor/nft/out/index.js +25 -0
  131. package/vendor/nft/out/index.js.map +1 -0
  132. package/vendor/nft/out/node-file-trace.d.ts +41 -0
  133. package/vendor/nft/out/node-file-trace.js +380 -0
  134. package/vendor/nft/out/node-file-trace.js.map +1 -0
  135. package/vendor/nft/out/resolve-dependency.d.ts +6 -0
  136. package/vendor/nft/out/resolve-dependency.js +427 -0
  137. package/vendor/nft/out/resolve-dependency.js.map +1 -0
  138. package/vendor/nft/out/types.d.ts +66 -0
  139. package/vendor/nft/out/types.js +2 -0
  140. package/vendor/nft/out/types.js.map +1 -0
  141. package/vendor/nft/out/utils/ast-helpers.js +49 -0
  142. package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
  143. package/vendor/nft/out/utils/binary-locators.js +88 -0
  144. package/vendor/nft/out/utils/binary-locators.js.map +1 -0
  145. package/vendor/nft/out/utils/get-package-base.js +30 -0
  146. package/vendor/nft/out/utils/get-package-base.js.map +1 -0
  147. package/vendor/nft/out/utils/interop-require.js +24 -0
  148. package/vendor/nft/out/utils/interop-require.js.map +1 -0
  149. package/vendor/nft/out/utils/node-version.js +10 -0
  150. package/vendor/nft/out/utils/node-version.js.map +1 -0
  151. package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
  152. package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
  153. package/vendor/nft/out/utils/special-cases.js +347 -0
  154. package/vendor/nft/out/utils/special-cases.js.map +1 -0
  155. package/vendor/nft/out/utils/static-eval.js +579 -0
  156. package/vendor/nft/out/utils/static-eval.js.map +1 -0
  157. package/vendor/nft/out/utils/types.js +2 -0
  158. package/vendor/nft/out/utils/types.js.map +1 -0
  159. package/vendor/nft/out/utils/wrappers.js +659 -0
  160. package/vendor/nft/out/utils/wrappers.js.map +1 -0
  161. package/vendor/nft/package.json +4 -0
  162. package/vendor/nft/upstream.json +51 -0
@@ -1,36 +1,27 @@
1
- import { parse } from '@babel/parser';
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 root = path.resolve(directory);
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: 1, root, ok: false, status: 'checked', files: 0, routes: preflight.routes,
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 does not execute application routes or inspect every installed dependency. Run a production build and browser tests before switching.',
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
- const source = await readFile(filename, 'utf8');
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]?js|jsx|tsx?)$/.test(entry.name) && !entry.name.endsWith('.d.ts')) result.sources.push(file);
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
- const application = path.relative(owner.directory, projectRoot);
124
- if (application === '' || (!application.startsWith('..' + path.sep) && application !== '..' && !path.isAbsolute(application))) return;
125
- if (!await nativeReachable(owner)) return;
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.` }] };
@@ -0,0 +1,32 @@
1
+ import {readFile, writeFile, stat} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import {partialShell, sha256, NATIVE_PPR_ARTIFACT_BYTES, NATIVE_PPR_PREFIX_BYTES} from '../runtime/native-ppr.mjs';
4
+
5
+ // Only concrete build-time shells are certified. Runtime regeneration, generic
6
+ // params, middleware and external caches stay on the authoritative Node path.
7
+ export async function prepareNativePprShells(stage, manifest) {
8
+ delete manifest.config.nativePprArtifacts;
9
+ if (!manifest.config.nativePprShell || !manifest.config.cacheComponents || manifest.dev || manifest.middleware || manifest.config.basePath || manifest.config.cacheHandler || Object.keys(manifest.config.cacheHandlers || {}).length) return;
10
+ const descriptors = [];
11
+ let total = 0;
12
+ for (const route of [...manifest.routes].sort((a, b) => a.id.localeCompare(b.id))) {
13
+ if (route.kind !== 'page' || route.router !== 'app' || route.internal || route.parallel || route.cacheConfig?.runtime === 'edge') continue;
14
+ for (const [pathname, artifactFile] of Object.entries(route.ppr || {}).sort(([a], [b]) => a.localeCompare(b))) {
15
+ if (descriptors.length >= 32) break;
16
+ const file = path.resolve(stage, artifactFile);
17
+ if (!artifactFile.startsWith('static/') || !file.startsWith(path.resolve(stage) + path.sep) || (await stat(file)).size > NATIVE_PPR_ARTIFACT_BYTES) continue;
18
+ const bytes = await readFile(file);
19
+ const artifact = JSON.parse(bytes);
20
+ if (artifact.version !== 1 || !artifact.postponed || artifact.complete || artifact.generic || artifact.externalCache || artifact.revalidate === 0 || typeof artifact.flight !== 'string' || Object.keys(artifact.headers || {}).length) continue;
21
+ const prefix = Buffer.from(partialShell(artifact, route).replace(/<\/body><\/html>$/i, ''));
22
+ if (prefix.length > NATIVE_PPR_PREFIX_BYTES || total + prefix.length > 4 * 1024 * 1024) continue;
23
+ if (!Number.isSafeInteger(artifact.generatedAt) || artifact.generatedAt < 0 || !(artifact.revalidate == null || artifact.revalidate === false || Number.isSafeInteger(artifact.revalidate) && artifact.revalidate > 0)) continue;
24
+ const prefixHash = sha256(prefix);
25
+ const prefixFile = `static/native-ppr-${prefixHash}.html`;
26
+ await writeFile(path.join(stage, prefixFile), prefix);
27
+ descriptors.push({routeId: route.id, path: pathname, artifact: artifactFile, artifactHash: sha256(bytes), prefix: prefixFile, prefixHash, identity: sha256(artifact.flight), generatedAt: artifact.generatedAt, revalidate: artifact.revalidate ?? false});
28
+ total += prefix.length;
29
+ }
30
+ }
31
+ manifest.config.nativePprArtifacts = descriptors;
32
+ }