@thomas.f/prnext 0.1.17-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.
Files changed (202) hide show
  1. package/README.md +27 -868
  2. package/build/actions.mjs +27 -15
  3. package/build/analyze.mjs +180 -24
  4. package/build/app-config.mjs +13 -0
  5. package/build/app-static.mjs +34 -12
  6. package/build/app-styles.mjs +79 -0
  7. package/build/app.mjs +122 -46
  8. package/build/bundle-runtime.mjs +22 -9
  9. package/build/cache-components.mjs +1 -1
  10. package/build/cache-handlers.mjs +1 -1
  11. package/build/compact-css.mjs +53 -0
  12. package/build/compilation-cache.mjs +269 -0
  13. package/build/compilation-store.mjs +142 -0
  14. package/build/compiler.mjs +34 -10
  15. package/build/config.mjs +34 -3
  16. package/build/create.mjs +239 -0
  17. package/build/css-modules.mjs +56 -12
  18. package/build/deployment-assets.mjs +89 -0
  19. package/build/deployment-config.mjs +18 -0
  20. package/build/dev-css-assets.mjs +173 -0
  21. package/build/dev-css-update.mjs +18 -14
  22. package/build/dev-dependencies.mjs +1 -1
  23. package/build/dev-discovery.mjs +3 -3
  24. package/build/dev-lazy-build.mjs +77 -0
  25. package/build/dev.mjs +15 -8
  26. package/build/dynamic.mjs +14 -7
  27. package/build/edge.mjs +3 -3
  28. package/build/fonts.mjs +1 -1
  29. package/build/framework-imports.mjs +15 -5
  30. package/build/html-only-config.mjs +53 -0
  31. package/build/html-only-graph.mjs +67 -0
  32. package/build/incremental.mjs +112 -18
  33. package/build/index.mjs +90 -28
  34. package/build/instrumentation.mjs +30 -0
  35. package/build/lazy-dynamic-imports.mjs +137 -0
  36. package/build/metadata-routes.mjs +1 -1
  37. package/build/migrate.mjs +4 -4
  38. package/build/migration-audit.mjs +19 -31
  39. package/build/migration-imports.mjs +245 -0
  40. package/build/migration-scenarios-runner.mjs +73 -0
  41. package/build/migration-scenarios.mjs +72 -0
  42. package/build/native-discovery.mjs +65 -5
  43. package/build/native-packages.mjs +2 -0
  44. package/build/native-type-config.mjs +37 -0
  45. package/build/npm-packages.mjs +1 -0
  46. package/build/persistent-cache.mjs +18 -3
  47. package/build/precompress.mjs +1 -1
  48. package/build/prerender-cache.mjs +70 -0
  49. package/build/prerender-config.mjs +29 -0
  50. package/build/prerender-pages.mjs +19 -9
  51. package/build/prerender-pool.mjs +165 -0
  52. package/build/prerender-process-worker.mjs +58 -0
  53. package/build/prerender-worker.mjs +5 -5
  54. package/build/production-builder.mjs +59 -10
  55. package/build/react-compiler-report.mjs +225 -0
  56. package/build/react-compiler.mjs +139 -0
  57. package/build/runtime-contract.mjs +2 -2
  58. package/build/standalone.mjs +21 -3
  59. package/build/static-export.mjs +4 -4
  60. package/build/styles.mjs +6 -4
  61. package/build/transform.mjs +1 -1
  62. package/build/typed-routes.mjs +4 -0
  63. package/build/typescript-dev.mjs +35 -8
  64. package/build/typescript-native-worker.mjs +55 -0
  65. package/build/typescript-native.mjs +18 -0
  66. package/build/typescript-toolchain.mjs +19 -0
  67. package/build/typescript.mjs +6 -4
  68. package/build/webpack-graph.mjs +19 -6
  69. package/build/worker-memory.mjs +9 -0
  70. package/cli.mjs +70 -5
  71. package/compat/activity-cache.cjs +26 -0
  72. package/compat/after.cjs +6 -4
  73. package/compat/app-dynamic.cjs +12 -1
  74. package/compat/app-layout-context.cjs +52 -2
  75. package/compat/base64.cjs +10 -0
  76. package/compat/cache-handlers.cjs +15 -3
  77. package/compat/cache-wire.cjs +28 -0
  78. package/compat/cache.cjs +2 -1
  79. package/compat/cache.d.cts +4 -0
  80. package/compat/cookies.cjs +21 -5
  81. package/compat/data-cache.cjs +12 -8
  82. package/compat/flight-delta.cjs +152 -0
  83. package/compat/headers.cjs +4 -2
  84. package/compat/incremental-cache.cjs +20 -7
  85. package/compat/index.d.ts +69 -4
  86. package/compat/link.cjs +13 -10
  87. package/compat/navigation-authorization.cjs +9 -0
  88. package/compat/navigation-stages.cjs +19 -0
  89. package/compat/offline-store.cjs +11 -0
  90. package/compat/offline.cjs +5 -0
  91. package/compat/offline.d.cts +2 -0
  92. package/compat/redis-cache.cjs +201 -0
  93. package/compat/redis-cache.d.cts +37 -0
  94. package/compat/server-primitives.cjs +31 -11
  95. package/compat/server.d.cts +15 -2
  96. package/compat/static-generation.cjs +7 -0
  97. package/compat/use-cache.cjs +3 -0
  98. package/docs/ai/compatibility.md +248 -2
  99. package/docs/ai/data.md +165 -5
  100. package/docs/ai/deployment.md +158 -0
  101. package/docs/ai/quickstart.md +29 -4
  102. package/docs/ai/routing.md +93 -0
  103. package/docs/ai/troubleshooting.md +116 -2
  104. package/package.json +40 -9
  105. package/runtime/action-client.mjs +10 -4
  106. package/runtime/action-server.mjs +2 -0
  107. package/runtime/app-client.mjs +316 -92
  108. package/runtime/app-content.mjs +4 -3
  109. package/runtime/app-partial-client.mjs +3 -0
  110. package/runtime/app-partial.mjs +1 -1
  111. package/runtime/app-prefetch-constants.mjs +4 -0
  112. package/runtime/app-prefetch.mjs +2 -3
  113. package/runtime/app-render-key.mjs +5 -0
  114. package/runtime/app-render.mjs +88 -28
  115. package/runtime/app-routing.mjs +22 -9
  116. package/runtime/app-static.mjs +18 -8
  117. package/runtime/app-stream-html.mjs +10 -53
  118. package/runtime/app-style-selection.mjs +34 -0
  119. package/runtime/app-styles.mjs +154 -0
  120. package/runtime/atomic-file.mjs +27 -0
  121. package/runtime/cache-components-rsc.mjs +7 -2
  122. package/runtime/cache-request.mjs +2 -1
  123. package/runtime/deployment-client.mjs +19 -0
  124. package/runtime/dev-client.mjs +13 -3
  125. package/runtime/dev-compiler-broker.mjs +42 -0
  126. package/runtime/dev-file-changes.mjs +123 -8
  127. package/runtime/dev-indicator.mjs +8 -3
  128. package/runtime/dev-lazy-imports.mjs +121 -0
  129. package/runtime/dev-lazy-staging.mjs +14 -0
  130. package/runtime/dev-lazy-tool.mjs +16 -0
  131. package/runtime/dev-style-retention.mjs +15 -0
  132. package/runtime/development.mjs +83 -25
  133. package/runtime/document.mjs +1 -1
  134. package/runtime/edge.mjs +6 -5
  135. package/runtime/fetch-cache.mjs +10 -8
  136. package/runtime/flight-delta.mjs +74 -0
  137. package/runtime/flight-tap.mjs +28 -0
  138. package/runtime/fs.mjs +15 -8
  139. package/runtime/html-only.mjs +27 -0
  140. package/runtime/http.mjs +2 -0
  141. package/runtime/incremental-cache.mjs +25 -4
  142. package/runtime/instrumentation.mjs +49 -0
  143. package/runtime/lazy-import-client.mjs +38 -0
  144. package/runtime/navigation-recovery.mjs +36 -0
  145. package/runtime/navigation.mjs +7 -0
  146. package/runtime/node-html-stream.mjs +47 -0
  147. package/runtime/pages-client.mjs +14 -7
  148. package/runtime/pages-navigation.mjs +48 -63
  149. package/runtime/prefetch-scheduler.mjs +110 -0
  150. package/runtime/profile.mjs +14 -0
  151. package/runtime/profiles.json +2 -2
  152. package/runtime/render.mjs +3 -0
  153. package/runtime/route-pattern.mjs +44 -0
  154. package/runtime/rsc-worker.mjs +34 -12
  155. package/runtime/trace.mjs +78 -0
  156. package/runtime/watch-project.mjs +19 -15
  157. package/runtime/worker.mjs +70 -26
  158. package/vendor/nft/LICENSE +7 -0
  159. package/vendor/nft/README.md +16 -0
  160. package/vendor/nft/out/analyze.d.ts +8 -0
  161. package/vendor/nft/out/analyze.js +1216 -0
  162. package/vendor/nft/out/analyze.js.map +1 -0
  163. package/vendor/nft/out/cli.d.ts +2 -0
  164. package/vendor/nft/out/cli.js +94 -0
  165. package/vendor/nft/out/cli.js.map +1 -0
  166. package/vendor/nft/out/fs.d.ts +22 -0
  167. package/vendor/nft/out/fs.js +106 -0
  168. package/vendor/nft/out/fs.js.map +1 -0
  169. package/vendor/nft/out/index.d.ts +4 -0
  170. package/vendor/nft/out/index.js +25 -0
  171. package/vendor/nft/out/index.js.map +1 -0
  172. package/vendor/nft/out/node-file-trace.d.ts +41 -0
  173. package/vendor/nft/out/node-file-trace.js +380 -0
  174. package/vendor/nft/out/node-file-trace.js.map +1 -0
  175. package/vendor/nft/out/resolve-dependency.d.ts +6 -0
  176. package/vendor/nft/out/resolve-dependency.js +427 -0
  177. package/vendor/nft/out/resolve-dependency.js.map +1 -0
  178. package/vendor/nft/out/types.d.ts +66 -0
  179. package/vendor/nft/out/types.js +2 -0
  180. package/vendor/nft/out/types.js.map +1 -0
  181. package/vendor/nft/out/utils/ast-helpers.js +49 -0
  182. package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
  183. package/vendor/nft/out/utils/binary-locators.js +88 -0
  184. package/vendor/nft/out/utils/binary-locators.js.map +1 -0
  185. package/vendor/nft/out/utils/get-package-base.js +30 -0
  186. package/vendor/nft/out/utils/get-package-base.js.map +1 -0
  187. package/vendor/nft/out/utils/interop-require.js +24 -0
  188. package/vendor/nft/out/utils/interop-require.js.map +1 -0
  189. package/vendor/nft/out/utils/node-version.js +10 -0
  190. package/vendor/nft/out/utils/node-version.js.map +1 -0
  191. package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
  192. package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
  193. package/vendor/nft/out/utils/special-cases.js +347 -0
  194. package/vendor/nft/out/utils/special-cases.js.map +1 -0
  195. package/vendor/nft/out/utils/static-eval.js +579 -0
  196. package/vendor/nft/out/utils/static-eval.js.map +1 -0
  197. package/vendor/nft/out/utils/types.js +2 -0
  198. package/vendor/nft/out/utils/types.js.map +1 -0
  199. package/vendor/nft/out/utils/wrappers.js +659 -0
  200. package/vendor/nft/out/utils/wrappers.js.map +1 -0
  201. package/vendor/nft/package.json +4 -0
  202. package/vendor/nft/upstream.json +51 -0
@@ -0,0 +1,137 @@
1
+ import path from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+ import {createHash} from 'node:crypto';
4
+ import {readFile,writeFile} from 'node:fs/promises';
5
+ import {parse} from '@babel/parser';
6
+ import traverseModule from '@babel/traverse';
7
+ import generateModule from '@babel/generator';
8
+ import * as t from '@babel/types';
9
+ import {generateMapped} from './source-maps.mjs';
10
+
11
+ const traverse=traverseModule.default||traverseModule;
12
+ const generate=generateModule.default||generateModule;
13
+ const packageRoot=fileURLToPath(new URL('../',import.meta.url));
14
+ const client=fileURLToPath(new URL('../runtime/lazy-import-client.mjs',import.meta.url));
15
+ export const LAZY_IMPORT_LIMIT=128;
16
+ export const LAZY_BRIDGE_LIMIT=4096;
17
+ const javascript=/\.(?:[cm]?[jt]s|[jt]sx)$/;
18
+ // esbuild exposes JSON as an ESM entry with a shared default object and named
19
+ // exports. Give it the same native URL identity as JavaScript export bridges.
20
+ const bridgeable=file=>javascript.test(file)||file.endsWith('.json');
21
+ const digest=value=>createHash('sha256').update(value).digest('hex').slice(0,24);
22
+ const suffixError=()=>({errors:[{text:'lazyDynamicImports cannot safely defer a module with a query or fragment suffix. Disable experimental.lazyDynamicImports to compile this graph normally.'}]});
23
+
24
+ /** Development-only graph boundaries. No source path is sent to the browser. */
25
+ export function createLazyDynamicImports({root,stage,manifest,request,activated=[]}){
26
+ const modules=new Map(),bridges=new Map(),shared=new Set(),existingShared=new Set(),entries=new Map(),targetFiles=new Map(),nativeOutputs=new Map(),outputs={};
27
+ const active=new Set(activated);
28
+ if(active.size>LAZY_IMPORT_LIMIT)throw Error(`lazyDynamicImports supports at most ${LAZY_IMPORT_LIMIT} active imports`);
29
+ if(request){
30
+ if(!/^[a-f0-9]{24}$/.test(request.id)||!Array.isArray(request.modules)||request.modules.length>LAZY_IMPORT_LIMIT||!Array.isArray(request.bridges)||request.bridges.length>LAZY_BRIDGE_LIMIT||!Array.isArray(request.shared)||request.shared.length>LAZY_BRIDGE_LIMIT)throw Error('Invalid deferred import compiler descriptor');
31
+ for(const item of request.modules){
32
+ const file=path.resolve(root,item.importer);
33
+ if(!file.startsWith(root+path.sep)||file.includes(`${path.sep}node_modules${path.sep}`)||!javascript.test(file)||typeof item.request!=='string'||item.request.length>4096||item.id!==digest(item.importer+'\0'+item.request))throw Error('Invalid deferred import registration');
34
+ modules.set(item.id,item);
35
+ }
36
+ if(!modules.has(request.id))throw Error('Unknown deferred import identifier');
37
+ for(const item of request.bridges){
38
+ if(typeof item.file!=='string'||typeof item.url!=='string'||!item.url.startsWith(manifest.config.assetBase+'/')||/[\r\n\0]/.test(item.url))throw Error('Invalid deferred module bridge');
39
+ bridges.set(path.resolve(root,item.file),item.url);
40
+ }
41
+ for(const file of request.shared){const absolute=path.resolve(root,file);shared.add(absolute);existingShared.add(absolute);}
42
+ active.clear();active.add(request.id);
43
+ }
44
+ const sourceAst=(source,file)=>parse(source,{sourceType:'unambiguous',sourceFilename:file,plugins:['jsx',...(/\.[cm]?tsx?$/.test(file)?['typescript']:[])]});
45
+ const eligible=file=>file.startsWith(root+path.sep)&&!file.includes(`${path.sep}node_modules${path.sep}`)&&!file.includes(`${path.sep}.prnext-build-`);
46
+ function transform(source,file){
47
+ if(!eligible(file)||!source.includes('import'))return source;
48
+ const ast=sourceAst(source,file);let binding,changed=false;
49
+ const replace=node=>{
50
+ const value=t.isStringLiteral(node)?node.value:t.isTemplateLiteral(node)&&!node.expressions.length?node.quasis[0].value.cooked:undefined;
51
+ // Suffixes are part of ESM identity; path-only export bridges cannot
52
+ // represent them. Keep explicit suffixes in the ordinary compiler graph.
53
+ if(!value||value.length>4096||/[?#]/.test(value)||/^(?:[a-z]+:|\/\/)/i.test(value))return;
54
+ const importer=path.relative(root,file).split(path.sep).join('/'),id=digest(importer+'\0'+value);
55
+ if(!modules.has(id)&&modules.size>=LAZY_IMPORT_LIMIT)throw Error(`lazyDynamicImports supports at most ${LAZY_IMPORT_LIMIT} registered imports per generation`);
56
+ modules.set(id,{id,importer,request:value});
57
+ changed=true;return id;
58
+ };
59
+ traverse(ast,{Program(p){binding=p.scope.generateUidIdentifier('prnextLazyImport');},CallExpression(p){
60
+ if(!t.isImport(p.node.callee)||p.node.arguments.length!==1)return;
61
+ const id=replace(p.node.arguments[0]);if(id)p.replaceWith(t.callExpression(t.cloneNode(binding),[t.stringLiteral(id),t.stringLiteral(manifest.config.basePath||'')]));
62
+ },ImportExpression(p){if(p.node.options)return;const id=replace(p.node.source);if(id)p.replaceWith(t.callExpression(t.cloneNode(binding),[t.stringLiteral(id),t.stringLiteral(manifest.config.basePath||'')]));}});
63
+ if(!changed)return source;
64
+ ast.program.body.unshift(t.importDeclaration([t.importSpecifier(t.cloneNode(binding),t.identifier('lazyImport'))],t.stringLiteral(client)));
65
+ return generateMapped(generate,ast,{comments:true},source,file);
66
+ }
67
+ async function discover(metafile){
68
+ for(const input of Object.keys(metafile.inputs)){
69
+ const file=path.resolve(root,input);
70
+ if(!bridgeable(file)||input.startsWith('prnext-')||file.startsWith(packageRoot)||file.startsWith(stage+path.sep)||/\/node_modules\/(?:react|react-dom|react-refresh|react-server-dom-webpack)\//.test(file.split(path.sep).join('/'))||shared.has(file))continue;
71
+ if(shared.size>=LAZY_BRIDGE_LIMIT)throw Error(`lazyDynamicImports supports at most ${LAZY_BRIDGE_LIMIT} static JavaScript modules`);
72
+ shared.add(file);
73
+ // Real ESM entries expose live bindings. CommonJS named exports cannot be
74
+ // approximated with snapshots: a deferred graph sharing one is rejected.
75
+ if(file.endsWith('.json')||sourceAst(await readFile(file,'utf8'),file).program.sourceType==='module')bridges.set(file,undefined);
76
+ }
77
+ }
78
+ async function targetEntries(){
79
+ const result={};
80
+ for(const id of active){
81
+ if(!modules.has(id))continue;
82
+ const name='lazy-'+id,file=path.join(stage,'.entries',name+'.mjs');
83
+ await writeFile(file,`import * as namespace from ${JSON.stringify('prnext-lazy-entry:'+id)};export {namespace as __prnextLazyModule};`);
84
+ entries.set(file,{kind:'target',id});result[name]=file;
85
+ }
86
+ return result;
87
+ }
88
+ function bridgeEntries(){
89
+ const result=Object.fromEntries([...bridges].filter(([,url])=>!url).map(([file])=>{entries.set(file,{kind:'bridge',file});return ['lazy-bridge-'+digest(file),file];}));
90
+ for(const file of targetFiles.values())if(!bridges.has(file)&&bridgeable(file)){
91
+ entries.set(file,{kind:'native',file});result['lazy-native-'+digest(file)]=file;
92
+ }
93
+ return result;
94
+ }
95
+ function plugin(){return {name:'prnext-lazy-dynamic-boundaries',setup(builder){
96
+ builder.onResolve({filter:/^prnext-lazy-entry:/},async args=>{
97
+ const item=modules.get(args.path.slice('prnext-lazy-entry:'.length));
98
+ if(!item)return {errors:[{text:'Unknown deferred module entry'}]};
99
+ const result=await builder.resolve(item.request,{resolveDir:path.dirname(path.resolve(root,item.importer)),kind:'dynamic-import',pluginData:{prnextLazyEntry:true}});
100
+ if(!result.errors.length&&result.suffix)return suffixError();
101
+ const file=path.resolve(result.path||root),url=bridges.get(file);
102
+ if(!result.errors.length&&result.namespace==='file')targetFiles.set(item.id,file);
103
+ if(existingShared.has(file)&&!url)return {errors:[{text:`lazyDynamicImports cannot safely reuse the shared CommonJS module ${path.relative(root,file)}. Convert it to ESM or disable experimental.lazyDynamicImports.`}]};
104
+ return url?{path:url,external:true}:result;
105
+ });
106
+ if(request)builder.onResolve({filter:/.*/},async args=>{
107
+ if(args.pluginData?.prnextLazyResolving||args.pluginData?.prnextLazyEntry||args.kind==='entry-point'||args.namespace&&args.namespace!=='file')return;
108
+ const result=await builder.resolve(args.path,{resolveDir:args.resolveDir,importer:args.importer,kind:args.kind,with:args.with,pluginData:{...args.pluginData,prnextLazyResolving:true}});
109
+ // Aliases and transitive imports may introduce a suffix even when the
110
+ // registered literal had none. Reject it before looking up a bare bridge.
111
+ if(!result.errors.length&&result.suffix)return suffixError();
112
+ if(result.errors.length||result.external||result.namespace!=='file')return;
113
+ const file=path.resolve(result.path);
114
+ if(bridges.get(file))return {path:bridges.get(file),external:true};
115
+ if(existingShared.has(file))return {errors:[{text:`lazyDynamicImports cannot safely reuse the shared CommonJS module ${path.relative(root,file)}. Convert it to ESM or disable experimental.lazyDynamicImports.`}]};
116
+ });
117
+ }};}
118
+ function record(metadata,output){
119
+ const entry=entries.get(path.resolve(root,metadata.entryPoint||''));
120
+ if(!entry||!output.endsWith('.js'))return false;
121
+ const url=manifest.config.assetBase+'/'+path.basename(output);
122
+ if(entry.kind==='bridge'||entry.kind==='native'){
123
+ if(entry.kind==='bridge')bridges.set(entry.file,url);
124
+ nativeOutputs.set(entry.file,{url,css:metadata.cssBundle?[manifest.config.assetBase+'/'+path.basename(metadata.cssBundle)]:[]});
125
+ }
126
+ else outputs[entry.id]={url,css:metadata.cssBundle?[manifest.config.assetBase+'/'+path.basename(metadata.cssBundle)]:[]};
127
+ return true;
128
+ }
129
+ function finish(){
130
+ for(const [id,file]of targetFiles){
131
+ const native=nativeOutputs.get(file),url=native?.url||bridges.get(file);
132
+ if(url&&outputs[id])outputs[id]={url,native:true,css:[...new Set([...outputs[id].css,...(native?.css||[])])]};
133
+ }
134
+ manifest.devLazy={modules:[...modules.values()],bridges:[...bridges].filter(([,url])=>url).map(([file,url])=>({file:path.relative(root,file).split(path.sep).join('/'),url})),shared:[...shared].map(file=>path.relative(root,file).split(path.sep).join('/')),outputs};
135
+ }
136
+ return {transform,discover,targetEntries,bridgeEntries,plugin,record,finish};
137
+ }
@@ -60,7 +60,7 @@ export async function prepareMetadataRoutes(project, stage) {
60
60
  let source;
61
61
  if (dynamic) {
62
62
  const config = await validateAppConfig(route.file);
63
- source = `import * as source from ${JSON.stringify(route.file)};import {metadataHandler,metadataStaticParams} from '../runtime/metadata-route.mjs';export * from ${JSON.stringify(route.file)};\n`;
63
+ source = `import * as source from ${JSON.stringify(route.file)};import {metadataHandler,metadataStaticParams} from 'prnext-internal:metadata-route';export * from ${JSON.stringify(route.file)};\n`;
64
64
  if (config.revalidate === undefined) source += 'export const revalidate=false;\n';
65
65
  const options = JSON.stringify({ kind, image, multiple, contentType });
66
66
  source += `export const GET=metadataHandler(source,${options});\n`;
package/build/migrate.mjs CHANGED
@@ -19,18 +19,18 @@ export function parseMigrationArgs(args) {
19
19
  const arg = args[index];
20
20
  if (arg === '--dry-run') options.dryRun = true;
21
21
  else if (arg === '--check') options.check = true;
22
- else if (['--against', '--candidate', '--routes', '--timeout'].includes(arg)) {
22
+ else if (['--against', '--candidate', '--routes', '--timeout', '--browser-scenarios'].includes(arg)) {
23
23
  const value = args[++index];
24
24
  if (!value || value.startsWith('--')) throw new Error(`${arg} requires a value.`);
25
- options[arg.slice(2)] = arg === '--timeout' ? Number(value) : value;
25
+ options[arg === '--browser-scenarios' ? 'browserScenarios' : arg.slice(2)] = arg === '--timeout' ? Number(value) : value;
26
26
  }
27
27
  else if (arg === '--no-install') options.install = false;
28
28
  else if (arg === '--json') options.json = true;
29
- else if (arg.startsWith('-')) throw new Error(`Unknown migrate option: ${arg}. Use --check, --dry-run, --no-install or --json.`);
29
+ else if (arg.startsWith('-')) throw new Error(`Unknown migrate option: ${arg}. Use --check, --dry-run, --no-install, --browser-scenarios or --json.`);
30
30
  else if (directory === undefined) directory = arg;
31
31
  else throw new Error('migrate accepts a single project directory.');
32
32
  }
33
- if ((options.against || options.candidate || options.routes || options.timeout !== undefined) && (!options.check || !options.against || !options.candidate)) throw new Error('Server comparison requires --check, --against and --candidate.');
33
+ if ((options.against || options.candidate || options.routes || options.timeout !== undefined || options.browserScenarios) && (!options.check || !options.against || !options.candidate)) throw new Error('Server comparison or browser execution requires --check, --against and --candidate.');
34
34
  return { directory: path.resolve(directory || '.'), options };
35
35
  }
36
36
 
@@ -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 => 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
+ });