@thomas.f/prnext 0.1.18-alpha → 0.1.19-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/README.md +2 -0
  2. package/build/actions.mjs +26 -14
  3. package/build/app.mjs +46 -25
  4. package/build/compiler.mjs +5 -4
  5. package/build/config.mjs +4 -2
  6. package/build/dev-css-assets.mjs +173 -0
  7. package/build/dev-css-update.mjs +14 -12
  8. package/build/dev-lazy-build.mjs +77 -0
  9. package/build/dynamic.mjs +13 -6
  10. package/build/incremental.mjs +112 -18
  11. package/build/index.mjs +32 -3
  12. package/build/lazy-dynamic-imports.mjs +137 -0
  13. package/build/migrate.mjs +4 -4
  14. package/build/migration-audit.mjs +19 -31
  15. package/build/migration-imports.mjs +245 -0
  16. package/build/migration-scenarios-runner.mjs +73 -0
  17. package/build/migration-scenarios.mjs +72 -0
  18. package/build/native-discovery.mjs +65 -5
  19. package/build/native-packages.mjs +2 -0
  20. package/build/npm-packages.mjs +1 -0
  21. package/build/production-builder.mjs +1 -1
  22. package/build/standalone.mjs +21 -3
  23. package/build/webpack-graph.mjs +13 -3
  24. package/cli.mjs +10 -1
  25. package/compat/app-dynamic.cjs +12 -1
  26. package/compat/cache-handlers.cjs +3 -1
  27. package/compat/flight-delta.cjs +3 -1
  28. package/compat/index.d.ts +1 -1
  29. package/compat/link.cjs +2 -2
  30. package/docs/ai/compatibility.md +77 -0
  31. package/docs/ai/routing.md +11 -0
  32. package/docs/ai/troubleshooting.md +79 -0
  33. package/package.json +21 -9
  34. package/runtime/app-client.mjs +119 -42
  35. package/runtime/atomic-file.mjs +27 -0
  36. package/runtime/cache-components-rsc.mjs +3 -1
  37. package/runtime/dev-client.mjs +7 -2
  38. package/runtime/dev-file-changes.mjs +123 -8
  39. package/runtime/dev-lazy-imports.mjs +121 -0
  40. package/runtime/dev-lazy-staging.mjs +14 -0
  41. package/runtime/dev-style-retention.mjs +15 -0
  42. package/runtime/development.mjs +60 -19
  43. package/runtime/flight-delta.mjs +28 -4
  44. package/runtime/fs.mjs +15 -8
  45. package/runtime/lazy-import-client.mjs +38 -0
  46. package/vendor/nft/LICENSE +7 -0
  47. package/vendor/nft/README.md +16 -0
  48. package/vendor/nft/out/analyze.d.ts +8 -0
  49. package/vendor/nft/out/analyze.js +1216 -0
  50. package/vendor/nft/out/analyze.js.map +1 -0
  51. package/vendor/nft/out/cli.d.ts +2 -0
  52. package/vendor/nft/out/cli.js +94 -0
  53. package/vendor/nft/out/cli.js.map +1 -0
  54. package/vendor/nft/out/fs.d.ts +22 -0
  55. package/vendor/nft/out/fs.js +106 -0
  56. package/vendor/nft/out/fs.js.map +1 -0
  57. package/vendor/nft/out/index.d.ts +4 -0
  58. package/vendor/nft/out/index.js +25 -0
  59. package/vendor/nft/out/index.js.map +1 -0
  60. package/vendor/nft/out/node-file-trace.d.ts +41 -0
  61. package/vendor/nft/out/node-file-trace.js +380 -0
  62. package/vendor/nft/out/node-file-trace.js.map +1 -0
  63. package/vendor/nft/out/resolve-dependency.d.ts +6 -0
  64. package/vendor/nft/out/resolve-dependency.js +427 -0
  65. package/vendor/nft/out/resolve-dependency.js.map +1 -0
  66. package/vendor/nft/out/types.d.ts +66 -0
  67. package/vendor/nft/out/types.js +2 -0
  68. package/vendor/nft/out/types.js.map +1 -0
  69. package/vendor/nft/out/utils/ast-helpers.js +49 -0
  70. package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
  71. package/vendor/nft/out/utils/binary-locators.js +88 -0
  72. package/vendor/nft/out/utils/binary-locators.js.map +1 -0
  73. package/vendor/nft/out/utils/get-package-base.js +30 -0
  74. package/vendor/nft/out/utils/get-package-base.js.map +1 -0
  75. package/vendor/nft/out/utils/interop-require.js +24 -0
  76. package/vendor/nft/out/utils/interop-require.js.map +1 -0
  77. package/vendor/nft/out/utils/node-version.js +10 -0
  78. package/vendor/nft/out/utils/node-version.js.map +1 -0
  79. package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
  80. package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
  81. package/vendor/nft/out/utils/special-cases.js +347 -0
  82. package/vendor/nft/out/utils/special-cases.js.map +1 -0
  83. package/vendor/nft/out/utils/static-eval.js +579 -0
  84. package/vendor/nft/out/utils/static-eval.js.map +1 -0
  85. package/vendor/nft/out/utils/types.js +2 -0
  86. package/vendor/nft/out/utils/types.js.map +1 -0
  87. package/vendor/nft/out/utils/wrappers.js +659 -0
  88. package/vendor/nft/out/utils/wrappers.js.map +1 -0
  89. package/vendor/nft/package.json +4 -0
  90. package/vendor/nft/upstream.json +51 -0
@@ -0,0 +1,77 @@
1
+ import { mkdir, readdir, lstat } from 'node:fs/promises';
2
+ import { randomUUID } from 'node:crypto';
3
+ import path from 'node:path';
4
+ import { compileApp } from './app.mjs';
5
+ import { configureCompiler } from './compiler.mjs';
6
+ import { createCssModules } from './css-modules.mjs';
7
+ import { createFonts } from './fonts.mjs';
8
+ import { createImages } from './images.mjs';
9
+ import { discardLazyBuild } from '../runtime/dev-lazy-staging.mjs';
10
+ import { loadEnvConfig } from '../runtime/env.mjs';
11
+
12
+ // Only the latest successful ordinary generation per project is reusable.
13
+ // This private compiler-process registry is not part of browser metadata or IPC.
14
+ const contexts = new Map();
15
+ const CONTEXT_LIMIT = 8, FILE_LIMIT = 16384, BYTE_LIMIT = 128 * 1024 * 1024;
16
+ export function forgetLazyBuildContext(root) {
17
+ if (root === undefined) contexts.clear(); else contexts.delete(root);
18
+ }
19
+ export function rememberLazyBuildContext(root, { manifest, config, environment, tsconfig, envMode }) {
20
+ contexts.delete(root);
21
+ if (!manifest.devLazy || !manifest.dev || !config.experimental.lazyDynamicImports) return;
22
+ while (contexts.size >= CONTEXT_LIMIT) contexts.delete(contexts.keys().next().value);
23
+ contexts.set(root, { config, environment, tsconfig, envMode, cacheId: manifest.cacheId,
24
+ buildId: manifest.buildId, session: manifest.devSession,
25
+ manifestConfig: structuredClone(manifest.config) });
26
+ }
27
+ /** Reuse the validated configuration of the running generation, not its routes. */
28
+ export async function tryBuildLazyGraph(root, { base, request, devInputs, devSession,
29
+ devActionSecret, incremental = true } = {}) {
30
+ if (!base || typeof base.cacheId !== 'string' || !request) throw Error('Invalid deferred build generation');
31
+ const context = contexts.get(root);
32
+ // Compiler recycling loses its private context. The caller retains the ordinary
33
+ // build path for this recovery case; it must never guess another generation.
34
+ if (!context || context.cacheId !== base.cacheId || context.session !== devSession) return;
35
+ // Preserve the running generation's env-file mode for supported build-time
36
+ // hooks. The enclosing build() scope restores the caller's env on every exit.
37
+ loadEnvConfig(root,{dev:true,mode:context.envMode,inputs:devInputs});
38
+ const { config, environment, tsconfig } = context;
39
+ const stage = path.join(root, '.prnext-build-' + randomUUID());
40
+ try {
41
+ await mkdir(path.join(stage, '.entries'), { recursive: true });
42
+ const manifest = { version: 1, dev: true, buildId: context.buildId,
43
+ cacheId: context.cacheId, devSession, config: structuredClone(context.manifestConfig),
44
+ routes: [], prerendered: [] };
45
+ const project = { root, routes: [] };
46
+ configureCompiler({ config, root, stage, buildId: context.buildId, dev: true,
47
+ incremental, devInputs, tsconfig, assetBase: manifest.config.assetBase });
48
+ const fonts = createFonts({ projectRoot: root, stage, assetBase: manifest.config.assetBase });
49
+ const cssModules = createCssModules({ projectRoot: root, stage,
50
+ assetBase: manifest.config.assetBase, sassOptions: config.sassOptions,
51
+ cssEngine: config.cssEngine, dev: true, config });
52
+ const images = await createImages({ projectRoot: root, stage,
53
+ assetBase: manifest.config.assetBase, config: config.images });
54
+ await compileApp({ project, stage, manifest, cssModules, fonts, images, dev: true,
55
+ devActionSecret, reactCompiler: config.reactCompiler, defineEnvironment: environment,
56
+ moduleResolution: config.turbopack, serverExternalPackages: config.serverExternalPackages,
57
+ lazyDynamicImports: true, devLazyRequest: request, devLazyOnly: true });
58
+ const files = [];
59
+ let bytes = 0;
60
+ // This unpublished stage contains only this deferred graph, never other
61
+ // routes or a copied framework runtime. Publication uses this exact receipt.
62
+ for (const entry of await readdir(path.join(stage, 'assets'), { withFileTypes: true })) {
63
+ if (!entry.isFile() || files.length >= FILE_LIMIT) throw Error('Invalid or excessive deferred output assets');
64
+ const info = await lstat(path.join(stage, 'assets', entry.name));
65
+ bytes += info.size;
66
+ if (!info.isFile() || bytes > BYTE_LIMIT) throw Error('Deferred output exceeds the 128 MiB generation budget');
67
+ files.push({ name: entry.name, bytes: info.size });
68
+ }
69
+ files.sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
70
+ return { ...manifest, outputDirectory: stage, devLazyTemporary: true,
71
+ devLazyAssets: { version: 1, baseCacheId: context.cacheId, files } };
72
+ } catch (error) {
73
+ try { await discardLazyBuild(root, { outputDirectory: stage, devLazyTemporary: true }); }
74
+ catch (cleanupError) { error.cleanupError = cleanupError; error.message += '; deferred compiler staging cleanup also failed'; }
75
+ throw error;
76
+ }
77
+ }
package/build/dynamic.mjs CHANGED
@@ -59,17 +59,19 @@ function loaderBody(loader) {
59
59
  }
60
60
 
61
61
  /** Add shared hydration IDs without resolving, evaluating or eagerly importing loaders. */
62
- export function transformDynamicImports(source, filename, { projectRoot, mode = 'browser' } = {}) {
62
+ export function transformDynamicImports(source, filename, { projectRoot, mode = 'browser',dev=false } = {}) {
63
63
  if (![...sources].some(name => source.includes(name))) return source;
64
64
  projectRoot ||= process.cwd();
65
- return cachedTransform('dynamic',source,[filename,projectRoot,mode],()=>transformDynamicImportsUncached(source,filename,{projectRoot,mode}));
65
+ return cachedTransform('dynamic',source,[filename,projectRoot,mode,dev],()=>transformDynamicImportsUncached(source,filename,{projectRoot,mode,dev}));
66
66
  }
67
67
 
68
- function transformDynamicImportsUncached(source, filename, {projectRoot,mode}) {
68
+ function transformDynamicImportsUncached(source, filename, {projectRoot,mode,dev}) {
69
69
  const ast = parse(source, { sourceType: 'unambiguous', sourceFilename: filename,
70
70
  plugins: ['jsx', ...(/\.[cm]?tsx?$/.test(filename) ? ['typescript'] : [])] });
71
71
  const relative = path.relative(projectRoot || process.cwd(), filename).replaceAll(path.sep, '/');
72
+ const developmentRevision=dev&&mode==='browser'?createHash('sha256').update(generate(ast,{compact:true,comments:false}).code).digest('hex'):undefined;
72
73
  let changed = false;
74
+ let ordinal=0;
73
75
  traverse(ast, {
74
76
  CallExpression(call) {
75
77
  let callee = call.get('callee');
@@ -105,6 +107,10 @@ function transformDynamicImportsUncached(source, filename, {projectRoot,mode}) {
105
107
  const generated = t.objectProperty(t.identifier('loadableGenerated'), t.objectExpression([
106
108
  t.objectProperty(t.identifier('modules'), t.arrayExpression(ids.map(id => t.stringLiteral(id)))),
107
109
  ]));
110
+ const statement=call.findParent(value=>value.isStatement());
111
+ const declaration=statement?.parentPath?.isExportNamedDeclaration()?statement.parentPath:statement;
112
+ const stableModuleCall=call.scope===call.scope.getProgramParent()&&declaration?.parentPath?.isProgram()&&!t.isLoop(declaration.node);
113
+ const identity=developmentRevision&&stableModuleCall?t.objectProperty(t.identifier('__prnextDevIdentity'),t.stringLiteral(createHash('sha256').update(relative+'\0'+ordinal+++'\0'+developmentRevision).digest('hex').slice(0,24))):undefined;
108
114
 
109
115
  if (noSSR && mode !== 'browser') {
110
116
  const empty = t.arrowFunctionExpression([], t.nullLiteral(), true);
@@ -115,9 +121,10 @@ function transformDynamicImportsUncached(source, filename, {projectRoot,mode}) {
115
121
  loader.replaceWith(t.arrowFunctionExpression([], loader.node));
116
122
  }
117
123
  const target = secondOptions || firstOptions;
118
- if (target?.isObjectExpression()) target.node.properties.push(generated);
119
- else if (target?.node) target.replaceWith(t.objectExpression([t.spreadElement(target.node), generated]));
120
- else call.node.arguments.push(t.objectExpression([generated]));
124
+ const fields=[generated,...(identity?[identity]:[])];
125
+ if (target?.isObjectExpression()) target.node.properties.push(...fields);
126
+ else if (target?.node) target.replaceWith(t.objectExpression([t.spreadElement(target.node), ...fields]));
127
+ else call.node.arguments.push(t.objectExpression(fields));
121
128
  changed = true;
122
129
  },
123
130
  });
@@ -1,14 +1,76 @@
1
1
  import { context } from 'esbuild';
2
2
  import { readFile, writeFile, mkdir, realpath } from 'node:fs/promises';
3
+ import { realpath as legacyRealpath } from 'node:fs';
3
4
  import { createHash } from 'node:crypto';
4
5
  import path from 'node:path';
5
6
  import {clearDirectoryCache} from './directory-cache.mjs';
6
7
  import {closeWebpackCompiler,webpackCompilerStats} from './webpack-graph.mjs';
7
8
  const contexts = new Map();
8
9
  const LIMIT = 8;
9
- let created = 0, reused = 0;
10
- export const incrementalCompilerStats = () => ({contexts:contexts.size, created, reused,webpack:webpackCompilerStats()});
10
+ const AUXILIARY_LIMIT = 2;
11
+ const RESERVES = Object.freeze({ main: 6, styles: 2 });
12
+ const counters = () => ({ created: 0, reused: 0, evicted: 0 });
13
+ const roles = () => ({ main: counters(), styles: counters(), auxiliary: counters() });
14
+ const totals = counters(), roleTotals = roles();
15
+
16
+ function record(metrics, role, event) {
17
+ totals[event]++;
18
+ roleTotals[role][event]++;
19
+ metrics[event]++;
20
+ metrics.roles[role][event]++;
21
+ }
22
+
23
+ function roleStats(metrics, active) {
24
+ return Object.fromEntries(Object.entries(metrics).map(([role, values]) => [role, {
25
+ ...values,
26
+ contexts: active.filter(slot => slot.role === role).length,
27
+ }]));
28
+ }
29
+
30
+ export function incrementalCompilerStats() {
31
+ const active = [...contexts.values()];
32
+ // Counters belong to retained projects, rather than an unbounded registry of
33
+ // every project ever compiled by this process. Global role totals persist.
34
+ const projects = [...new Set(active.map(slot => slot.metrics))].map(metrics => {
35
+ const retained = active.filter(slot => slot.metrics === metrics);
36
+ return {
37
+ root: metrics.root,
38
+ created: metrics.created,
39
+ reused: metrics.reused,
40
+ evicted: metrics.evicted,
41
+ contexts: retained.length,
42
+ roles: roleStats(metrics.roles, retained),
43
+ };
44
+ });
45
+ return {
46
+ contexts: contexts.size,
47
+ ...totals,
48
+ roles: roleStats(roleTotals, active),
49
+ projects,
50
+ limits: { project: LIMIT, auxiliary: AUXILIARY_LIMIT, reserves: { ...RESERVES } },
51
+ webpack: webpackCompilerStats(),
52
+ };
53
+ }
54
+
55
+ function evictionCandidate(group, role) {
56
+ const candidates = [...contexts].filter(([, slot]) => slot.group === group);
57
+ if (group === 'auxiliary') return candidates[0];
58
+ const count = candidateRole => candidates.filter(([, slot]) => slot.role === candidateRole).length;
59
+ // Both roles can borrow unused capacity. Once full, a role at its reserve
60
+ // replaces its own oldest graph; a role below its reserve reclaims a loan.
61
+ if (count(role) >= RESERVES[role]) return candidates.find(([, slot]) => slot.role === role);
62
+ return candidates.find(([, slot]) => count(slot.role) > RESERVES[slot.role]);
63
+ }
11
64
  export async function closeIncrementalCompiler(root) {
65
+ if (root !== undefined) {
66
+ try { root = await realpath(root); }
67
+ catch (error) {
68
+ // Disposal also runs after users remove a project directory. A supplied
69
+ // canonical path must still match its retained contexts in that case.
70
+ if (error.code !== 'ENOENT' && error.code !== 'ENOTDIR') throw error;
71
+ root = path.resolve(root);
72
+ }
73
+ }
12
74
  clearDirectoryCache();
13
75
  const active=[];for(const [key,slot] of contexts)if(root===undefined||slot.root===root){contexts.delete(key);active.push(slot);}
14
76
  const results=await Promise.allSettled([...active.map(slot=>slot.context.dispose()),closeWebpackCompiler(root)]);
@@ -16,18 +78,42 @@ export async function closeIncrementalCompiler(root) {
16
78
  if(errors.length)throw new AggregateError(errors,'Unable to dispose incremental compiler sessions');
17
79
  }
18
80
  /** Retain esbuild's parsed module graphs while rebinding per-build framework plugins. */
19
- export async function incrementalBuild(options, {root,stage,group='project',run=callback=>callback()}) {
20
- const canonicalRoot = await realpath(root);
21
- if (canonicalRoot !== root) {
22
- const canonical = value => typeof value === 'string' ? value.replaceAll(root, canonicalRoot) : Array.isArray(value) ? value.map(canonical) : value && typeof value === 'object' && !(value instanceof RegExp) && !ArrayBuffer.isView(value) ? Object.fromEntries(Object.entries(value).map(([key,item]) => [key,canonical(item)])) : value;
23
- return incrementalBuild(canonical(options),{root:canonicalRoot,stage:stage.replace(root,canonicalRoot),group,run});
81
+ export async function incrementalBuild(options, {root,stage,group='project',role=group==='auxiliary'?'auxiliary':'main',run=callback=>callback()}) {
82
+ if (!['project', 'auxiliary'].includes(group) || (group === 'project' ? !Object.hasOwn(RESERVES, role) : role !== 'auxiliary')) {
83
+ throw new TypeError('Invalid incremental compiler group or role');
24
84
  }
85
+ // Canonical identity is for ownership and disposal only. Compilation paths
86
+ // and application strings retain the caller's lexical spelling.
87
+ const ownerRoot = await realpath(root);
25
88
  const virtual=path.join(root,'.prnext-incremental-stage');
89
+ const rootPaths=[root,ownerRoot];
90
+ if(process.platform==='win32'&&root!==ownerRoot){
91
+ // Windows resolvers can preserve a short ancestor while resolving the
92
+ // junction target. The JS realpath walk retains that third spelling.
93
+ rootPaths.push(await new Promise((resolve,reject)=>legacyRealpath(root,(error,value)=>error?reject(error):resolve(value))));
94
+ }
95
+ const virtualPaths=[...new Set(rootPaths.map(directory=>path.join(directory,'.prnext-incremental-stage')))];
96
+ const stageRelative=path.relative(root,stage);
97
+ const stagePaths=[...new Set([stage,...rootPaths.map(directory=>path.resolve(directory,stageRelative))])];
98
+ const relativePath=(directory,value)=>{
99
+ if(typeof value!=='string'||!path.isAbsolute(value))return;
100
+ const relative=path.relative(directory,value);
101
+ if(relative===''||!path.isAbsolute(relative)&&relative!=='..'&&!relative.startsWith('..'+path.sep))return relative;
102
+ };
103
+ const withinPaths=(directories,value)=>directories.some(directory=>relativePath(directory,value)!==undefined);
104
+ const remapPath=(value,directories,target)=>{
105
+ for(const directory of directories){const relative=relativePath(directory,value);if(relative!==undefined)return path.join(target,relative);}
106
+ return value;
107
+ };
26
108
  const toVirtual=value=>typeof value==='string'?value.replaceAll(stage,virtual):value;
27
- const fromVirtual=value=>typeof value==='string'?value.replaceAll(virtual,stage):value;
109
+ // esbuild can canonicalize only the parent of a nonexistent virtual file.
110
+ // Recognize both staging identities on path fields, never application text.
111
+ const fromVirtual=value=>remapPath(value,[...virtualPaths,...stagePaths],stage);
112
+ const toVirtualPath=value=>remapPath(value,stagePaths,virtual);
113
+ const isVirtual=value=>withinPaths(virtualPaths,value),isStaged=value=>withinPaths(stagePaths,value);
28
114
  const deep=(value,map)=>typeof value==='string'?map(value):Array.isArray(value)?value.map(item=>deep(item,map)):value&&typeof value==='object'&&!(value instanceof RegExp)&&!ArrayBuffer.isView(value)?Object.fromEntries(Object.entries(value).map(([key,item])=>[map(key),deep(item,map)])):value;
29
115
  const realResult=result=>result && {...result,...(result.path?{path:fromVirtual(result.path)}:{}),...(result.resolveDir?{resolveDir:fromVirtual(result.resolveDir)}:{})};
30
- const virtualResult=result=>result && {...result,...(result.path?{path:toVirtual(result.path)}:{}),...(result.resolveDir?{resolveDir:toVirtual(result.resolveDir)}:{}),...(result.watchFiles?{watchFiles:result.watchFiles.map(toVirtual)}:{}),...(result.watchDirs?{watchDirs:result.watchDirs.map(toVirtual)}:{})};
116
+ const virtualResult=result=>result && {...result,...(result.path?{path:toVirtualPath(result.path)}:{}),...(result.resolveDir?{resolveDir:toVirtualPath(result.resolveDir)}:{}),...(result.watchFiles?{watchFiles:result.watchFiles.map(toVirtualPath)}:{}),...(result.watchDirs?{watchDirs:result.watchDirs.map(toVirtualPath)}:{})};
31
117
  const hooks=[];
32
118
  const initialOptions={...options,plugins:undefined,write:false};
33
119
  let slot;
@@ -39,18 +125,24 @@ export async function incrementalBuild(options, {root,stage,group='project',run=
39
125
  });
40
126
  const normalized=deep(initialOptions,toVirtual);
41
127
  const signature=JSON.stringify([normalized,hooks.map(({kind,settings})=>[kind,settings?.namespace,settings?.filter?.source,settings?.filter?.flags]),(options.plugins||[]).map(plugin=>plugin.name)]);
42
- const key=group+':'+createHash('sha256').update(signature).digest('hex');
128
+ const key=JSON.stringify([ownerRoot,root,group,role,createHash('sha256').update(signature).digest('hex')]);
43
129
  slot=contexts.get(key);
44
- if(slot){contexts.delete(key);reused++;}
130
+ if(slot){contexts.delete(key);record(slot.metrics,role,'reused');}
45
131
  else {
46
- while([...contexts.values()].filter(slot=>slot.group===group).length>=(group==='auxiliary'?2:LIMIT)){const [oldest,previous]=[...contexts].find(([,slot])=>slot.group===group);contexts.delete(oldest);await previous.context.dispose();}
47
- created++;slot={group,root};
48
- slot.current={hooks,run,toVirtual,fromVirtual,virtualResult,stage};
132
+ const metrics = [...contexts.values()].find(slot => slot.root === ownerRoot)?.metrics || { root: ownerRoot, ...counters(), roles: roles() };
133
+ while ([...contexts.values()].filter(slot => slot.group === group).length >= (group === 'auxiliary' ? AUXILIARY_LIMIT : LIMIT)) {
134
+ const [oldest, previous] = evictionCandidate(group, role);
135
+ contexts.delete(oldest);
136
+ record(previous.metrics, previous.role, 'evicted');
137
+ await previous.context.dispose();
138
+ }
139
+ slot={group,role,root:ownerRoot,metrics};
140
+ slot.current={hooks,run,toVirtual,fromVirtual,virtualResult,stage,isVirtual,isStaged};
49
141
  slot.context=await context({...normalized,plugins:[{name:'prnext-incremental-delegates',setup(builder){
50
142
  slot.builder=builder;
51
143
  builder.onResolve({filter:/.*/},async args=>{
52
144
  const active=slot.current;
53
- if(args.pluginData?.prnextVirtualResolution || !(args.path.startsWith(virtual+path.sep)||args.path.startsWith(active.stage+path.sep)||(args.resolveDir===virtual || args.resolveDir.startsWith(virtual+path.sep))))return;
145
+ if(args.pluginData?.prnextVirtualResolution || !(active.isVirtual(args.path)||active.isStaged(args.path)||active.isVirtual(args.resolveDir)||active.isStaged(args.resolveDir)))return;
54
146
  const result=await builder.resolve(active.fromVirtual(args.path),{resolveDir:active.fromVirtual(args.resolveDir),importer:active.fromVirtual(args.importer),kind:args.kind,with:args.with,pluginData:{...args.pluginData,prnextVirtualResolution:true}});
55
147
  const pluginData={...(result.pluginData || args.pluginData)}; delete pluginData.prnextVirtualResolution;
56
148
  return {...active.virtualResult(result),pluginData};
@@ -63,13 +155,15 @@ export async function incrementalBuild(options, {root,stage,group='project',run=
63
155
  if(settings)builder[kind](settings,callback);else builder[kind](callback);
64
156
  });
65
157
  builder.onLoad({filter:/.*/,namespace:'file'},async args=>{
66
- if(!args.path.startsWith(virtual+path.sep))return;
67
- const actual=slot.current.fromVirtual(args.path),extension=path.extname(actual);
158
+ const active=slot.current;
159
+ if(!active.isVirtual(args.path))return;
160
+ const actual=active.fromVirtual(args.path),extension=path.extname(actual);
68
161
  return {contents:await readFile(actual),loader:options.loader?.[extension] || ({'.json':'json','.ts':'ts','.tsx':'tsx','.jsx':'jsx','.css':'css'}[extension] || 'js'),resolveDir:path.dirname(args.path)};
69
162
  });
70
163
  }}]});
164
+ record(metrics,role,'created');
71
165
  }
72
- slot.current={hooks,run,toVirtual,fromVirtual,virtualResult,stage};
166
+ slot.current={hooks,run,toVirtual,fromVirtual,virtualResult,stage,isVirtual,isStaged};
73
167
  contexts.set(key,slot);
74
168
  const result=await slot.context.rebuild();
75
169
  const relativeVirtual=path.basename(virtual),relativeStage=path.basename(stage);
package/build/index.mjs CHANGED
@@ -13,6 +13,7 @@ import {retainDeploymentAssets} from './deployment-assets.mjs';
13
13
  import {selectHtmlOnlyRoutes} from './html-only-config.mjs';
14
14
  import {writeBuildAnalysis} from './analyze.mjs';
15
15
  import {routeFiles} from './dev-discovery.mjs';
16
+ import {tryBuildLazyGraph,rememberLazyBuildContext,forgetLazyBuildContext} from './dev-lazy-build.mjs';
16
17
  import {writeTypedRoutes} from './typed-routes.mjs';
17
18
  import { mkdir, readFile, writeFile, rm, cp, copyFile, stat, readdir, realpath } from 'node:fs/promises';
18
19
  import path from 'node:path';
@@ -154,7 +155,12 @@ let buildQueue = Promise.resolve();
154
155
  let developmentModulesLoaded = false;
155
156
  /** Disposing shared compiler contexts must not interrupt another queued build. */
156
157
  export function disposeBuildSession(root){
157
- const task=buildQueue.then(async()=>{const {closeIncrementalCompiler}=await import('./incremental.mjs');await closeIncrementalCompiler(root);});
158
+ const task=buildQueue.then(async()=>{
159
+ const canonicalRoot=root===undefined?undefined:await realpath(root).catch(error=>{if(error.code==='ENOENT'||error.code==='ENOTDIR')return path.resolve(root);throw error;});
160
+ forgetLazyBuildContext(canonicalRoot);
161
+ const {closeIncrementalCompiler}=await import('./incremental.mjs');
162
+ await closeIncrementalCompiler(canonicalRoot);
163
+ });
158
164
  buildQueue=task.catch(()=>{});return task;
159
165
  }
160
166
  /** Isolate process.env between programmatic builds, including simultaneous calls. */
@@ -184,8 +190,13 @@ export function build(projectRoot, options = {}) {
184
190
  }
185
191
 
186
192
  /** Compile Pages Router and App Router projects. A failed build preserves .prnext. */
187
- async function buildProject(projectRoot, { dev = false, envMode, incremental = false, devInputs, devRoutes, devSequence, devSession, devTools,devForceRefresh=false,devAffectedModules } = {}) {
193
+ async function buildProject(projectRoot, { dev = false, envMode, incremental = false, devInputs, devRoutes, devSequence, devSession, devActionSecret, devTools,devForceRefresh=false,devAffectedModules,devLazyRequest,devLazyBase,devLazyActivated=[] } = {}) {
188
194
  const started = performance.now();
195
+ if (dev && devLazyRequest && devLazyBase) {
196
+ const deferred = await tryBuildLazyGraph(projectRoot,{base:devLazyBase,request:devLazyRequest,
197
+ devInputs,devSession,devActionSecret,incremental});
198
+ if (deferred) return deferred;
199
+ }
189
200
  const config = await loadProjectConfig(projectRoot, { dev, envMode, devInputs });
190
201
  const tsconfig = config.typescript ? await projectTsconfig(projectRoot, config) : undefined;
191
202
  const environment = defineEnvironment(config);
@@ -201,6 +212,11 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
201
212
  deferredRoutes=discovery.routes.filter(route=>!devRoutes.includes(route.pattern)&&!route.internal&&!route.errorStatus);
202
213
  project.routes=project.routes.filter(route=>devRoutes.includes(route.pattern)||route.internal||route.errorStatus);
203
214
  } else project=await scanProject(projectRoot,scanOptions);
215
+ if(dev&&config.experimental.lazyDynamicImports){
216
+ if(project.routes.some(route=>route.router!=='app'&&!route.internal))throw Error('experimental.lazyDynamicImports currently requires an App Router-only application.');
217
+ if(config.webpack||Object.keys(config.turbopack?.rules||{}).length)throw Error('experimental.lazyDynamicImports currently requires the native compiler without webpack hooks or custom loaders.');
218
+ if(/^https?:\/\//.test(config.assetPrefix))throw Error('experimental.lazyDynamicImports requires same-origin development assets.');
219
+ }
204
220
  if(!dev&&config.htmlOnly.length)selectHtmlOnlyRoutes(project.routes,config.htmlOnly,config);
205
221
  if(!dev || !Array.isArray(devRoutes))await writeTypedRoutes(projectRoot,project,config);
206
222
  const stage = path.join(project.root, `.prnext-build-${randomUUID()}`);
@@ -296,7 +312,18 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
296
312
  await compileMiddleware({ project, stage, manifest, dev, moduleResolution: config.turbopack, defineEnvironment: environment });
297
313
  if (manifest.middleware) manifest.middleware.bodySizeLimit = config.experimental.proxyClientMaxBodySize;
298
314
  await prepareMetadataRoutes(project, stage);
299
- await compileApp({ project, stage, manifest, reactCompiler: config.reactCompiler, moduleResolution: config.turbopack, cssModules, fonts, images, dev, defineEnvironment: environment, productionBrowserSourceMaps: config.productionBrowserSourceMaps, serverExternalPackages: config.serverExternalPackages });
315
+ await compileApp({ project, stage, manifest, reactCompiler: config.reactCompiler, moduleResolution: config.turbopack, cssModules, fonts, images, dev, devActionSecret:dev?devActionSecret:undefined, defineEnvironment: environment, productionBrowserSourceMaps: config.productionBrowserSourceMaps, serverExternalPackages: config.serverExternalPackages, lazyDynamicImports:dev&&config.experimental.lazyDynamicImports,devLazyRequest,devLazyActivated });
316
+ if(manifest.devLazy){
317
+ const css=Object.values(manifest.devLazy.outputs).flatMap(module=>module.css);
318
+ for(const route of manifest.routes)if(route.router==='app'&&route.kind==='page'){
319
+ route.css=[...new Set([...route.css,...css])];
320
+ if(css.length&&Array.isArray(route.cssScopes)){
321
+ const root=route.cssScopes.find(scope=>scope.kind==='layout'&&!scope.path);
322
+ if(root)root.css=[...new Set([...root.css,...css])];
323
+ else route.cssScopes.push({kind:'layout',path:'',css:[...new Set(css)]});
324
+ }
325
+ }
326
+ }
300
327
  if(manifest.config.workerPreload)for(const pattern of config.workerPreload.routes){
301
328
  const selected=manifest.routes.find(route=>!route.internal&&route.pattern===pattern);
302
329
  if(!selected||selected.cacheConfig?.runtime==='edge'||selected.handlerConfig?.runtime==='edge')throw new Error(`workerPreload route ${pattern} must be an existing Node route.`);
@@ -353,6 +380,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
353
380
  }
354
381
  const appRoute = manifest.routes.find(route => route.router === 'app' && route.kind === 'page' && route.client);
355
382
  await writeFile(path.join(stage, 'assets', path.basename(manifest.devClient)), JSON.stringify({ buildId,
383
+ ...(manifest.devLazy?{lazy:Object.values(manifest.devLazy.outputs)}:{}),
356
384
  pages: manifest.routes.filter(route => route.router !== 'app' && route.client).map(publicPage),
357
385
  ...(appRoute ? { app: { client: appRoute.client, css: appRoute.css, routes: manifest.routes.filter(route=>route.router==='app'&&route.kind==='page'&&route.client).map(({id,pattern,css,cssScopes})=>({id,pattern,css,cssScopes:cssScopes||[]})) } } : {}),
358
386
  }));
@@ -393,6 +421,7 @@ async function buildProject(projectRoot, { dev = false, envMode, incremental = f
393
421
  } finally { await rm(pointerTemporary, {force:true}); }
394
422
  await exported?.commit();
395
423
  if (hadDestination) await rm(backup, { recursive: true, force: true });
424
+ if(dev&&!devLazyRequest)rememberLazyBuildContext(project.root,{manifest,config,environment,tsconfig,envMode});
396
425
  return { ...manifest, outputDirectory: destination, durationMs: Math.round(performance.now() - started),reactCompilerReport:compilerReport,compilationCache };
397
426
  } catch (error) {
398
427
  await rm(stage, { recursive: true, force: true });
@@ -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
+ }
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