@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
package/README.md CHANGED
@@ -93,6 +93,8 @@ npm run dev -- examples/app
93
93
 
94
94
  Source builds require Rust 1.98 and native build tools: Visual Studio C++ tools and a Windows SDK on Windows, Xcode command line tools on macOS, or a C/C++ toolchain and pkg-config on Linux. Install NASM on macOS, Linux and Windows x64.
95
95
 
96
+ For a larger integration example, see the [SaaS reference](https://github.com/Lorkydey/PRNext/tree/main/examples/saas-reference): Better Auth, PostgreSQL/Drizzle, French/English routing and a portable standalone build. It has its own dependency lockfile and database setup.
97
+
96
98
  </details>
97
99
 
98
100
  ## Documentation
package/build/actions.mjs CHANGED
@@ -9,28 +9,39 @@ import path from 'node:path';
9
9
 
10
10
  const traverse = traverseModule.default || traverseModule;
11
11
  const generate = generateModule.default || generateModule;
12
+ // Kept inside the compiler process. The development session is public metadata
13
+ // and must never be usable by itself to derive an action encryption key.
14
+ const processDevelopmentSecret = randomBytes(32);
12
15
  function hasDirective(node,value) { return node?.directives?.some(item=>item.value.value===value); }
13
16
  function removeDirective(node,value) { if(node?.directives) node.directives=node.directives.filter(item=>item.value.value!==value); }
14
17
 
15
- export function createServerActions({ projectRoot }) {
16
- const salt = randomBytes(32);
18
+ export function createServerActions({ projectRoot, developmentSession, developmentSecret }) {
19
+ if (developmentSession !== undefined && (typeof developmentSession !== 'string' || !developmentSession || developmentSession.length > 1024)) throw new TypeError('A development action session must be a non-empty string of at most 1024 characters');
20
+ if (developmentSecret !== undefined && (typeof developmentSecret !== 'string' || !/^[A-Za-z0-9+/]{43}=$/.test(developmentSecret))) throw new TypeError('A development action secret must encode 32 random bytes');
21
+ const privateSecret = developmentSecret ? Buffer.from(developmentSecret,'base64') : processDevelopmentSecret;
22
+ const derive = purpose => createHmac('sha256', privateSecret).update(JSON.stringify([purpose, path.resolve(projectRoot), developmentSession])).digest();
23
+ const salt = developmentSession ? derive('action-identities') : randomBytes(32);
17
24
  const modules = new Map();
18
25
  const actions = new Map();
19
26
  const configured = process.env.NEXT_SERVER_ACTIONS_ENCRYPTION_KEY;
20
27
  if (configured && (!/^[A-Za-z0-9+/]+={0,2}$/.test(configured) || ![16,24,32].includes(Buffer.from(configured,'base64').length))) throw new Error('NEXT_SERVER_ACTIONS_ENCRYPTION_KEY must be a base64-encoded 16, 24, or 32 byte AES key.');
21
- const actionKey = configured ? Buffer.from(configured,'base64').toString('base64') : randomBytes(32).toString('base64');
22
- function identity(file,name) { return createHmac('sha256',salt).update(path.relative(projectRoot,file).replaceAll(path.sep,'/')+'\0'+name).digest('hex'); }
23
- function register(file,name,binding) {
24
- const id=identity(file,name);
28
+ const actionKey = configured ? Buffer.from(configured,'base64').toString('base64') : (developmentSession ? derive('action-encryption') : randomBytes(32)).toString('base64');
29
+ function identity(file,name,revision) { return createHmac('sha256',salt).update(path.relative(projectRoot,file).replaceAll(path.sep,'/')+'\0'+name+(revision?'\0'+revision:'')).digest('hex'); }
30
+ function register(file,name,binding,revision) {
31
+ const id=identity(file,name,revision);
25
32
  if(!actions.has(id)) actions.set(id,{id,file,binding});
26
33
  return id;
27
34
  }
28
35
 
29
- function analyze(source,file) {
30
- const key=file+'\0'+createHash('sha256').update(source).digest('hex');
36
+ function analyze(source,file,{revisionSource=source}={}) {
37
+ const sourceHash=createHash('sha256').update(source).digest('hex');
38
+ // Cache transforms embed a generation ID. IDs for an unchanged action must
39
+ // follow its original source revision, not those ephemeral emitted bytes.
40
+ const revision=developmentSession?createHash('sha256').update(revisionSource).digest('hex'):sourceHash;
41
+ const key=file+'\0'+sourceHash+(developmentSession?'\0'+revision:'');
31
42
  if(modules.has(key))return modules.get(key);
32
43
  const template=JSON.parse(cachedTransform('server-action-analysis',source,[projectRoot,file],()=>JSON.stringify(analyzeTemplate(source,file))));
33
- const ids=new Map(template.registrations.map(item=>[item.id,register(file,item.name,item.binding)]));
44
+ const ids=new Map(template.registrations.map(item=>[item.id,register(file,item.name,item.binding,developmentSession?revision:undefined)]));
34
45
  let server=template.server;
35
46
  for(const [previous,current]of ids)server=server.replaceAll(previous,current);
36
47
  const result={...template,server,declared:template.declared.map(item=>({...item,id:ids.get(item.id)}))};
@@ -203,13 +214,14 @@ export function createServerActions({ projectRoot }) {
203
214
  return {
204
215
  actions, actionKey,
205
216
  // Only compiler-generated module-level action proxies bypass React Refresh.
206
- // Their random reference IDs change each build; they are not component exports.
207
- isServerModule(source,file) {
208
- return source.includes('use server') && analyze(source,file).moduleServer === true;
217
+ // Their opaque IDs are not component exports. Development keeps unchanged
218
+ // modules valid within one session; production IDs still change each build.
219
+ isServerModule(source,file,options) {
220
+ return source.includes('use server') && analyze(source,file,options).moduleServer === true;
209
221
  },
210
- transform(source,file,mode) {
222
+ transform(source,file,mode,options) {
211
223
  if(!source.includes('use server')) return source;
212
- const result=analyze(source,file);
224
+ const result=analyze(source,file,options);
213
225
  // A Route Handler invokes the implementation as ordinary server code.
214
226
  // It does not serialize functions through Flight or manufacture proxies.
215
227
  // Analyze first to preserve validation and the shared action registry.
package/build/app.mjs CHANGED
@@ -21,6 +21,7 @@ import {createReactCompilerTransform} from './react-compiler.mjs';
21
21
  import {createAppStyleGraph, appStyleScopes, planAppStyleEntries} from './app-styles.mjs';
22
22
  import {selectHtmlOnlyRoutes} from './html-only-config.mjs';
23
23
  import {createHtmlOnlyGraph} from './html-only-graph.mjs';
24
+ import {createLazyDynamicImports} from './lazy-dynamic-imports.mjs';
24
25
 
25
26
  const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
27
  const extensions = { '.js': 'jsx', '.jsx': 'jsx', '.mjs': 'jsx', '.cjs': 'jsx', '.ts': 'ts', '.tsx': 'tsx', '.mts': 'ts', '.cts': 'ts' };
@@ -177,16 +178,18 @@ function routeEntry(route, globalError, dev) {
177
178
  }
178
179
 
179
180
  /** Compile independent React Server and client graphs. The RSC graph contains only references to client boundaries. */
180
- export async function compileApp({ project, stage, manifest, cssModules, fonts, images, dev, reactCompiler = false, defineEnvironment = {}, productionBrowserSourceMaps = false, serverExternalPackages = [], moduleResolution = {} }) {
181
+ export async function compileApp({ project, stage, manifest, cssModules, fonts, images, dev, devActionSecret, reactCompiler = false, defineEnvironment = {}, productionBrowserSourceMaps = false, serverExternalPackages = [], moduleResolution = {},lazyDynamicImports=false,devLazyRequest,devLazyOnly=false,devLazyActivated=[] }) {
182
+ if(devLazyOnly&&(!dev||!lazyDynamicImports||!devLazyRequest))throw Error('Deferred-only compilation requires a registered development import');
181
183
  const refresh = dev ? createRefreshTransform(project.root, {generation:manifest.cacheId,forceGeneration:manifest.devForceRefresh,forceFiles:manifest.devAffectedModules}) : undefined;
182
184
  const locations = dev ? createSourceLocationTransform(project.root) : undefined;
183
185
  const compileReact = reactCompiler ? createReactCompilerTransform(project.root, reactCompiler, {dev}) : undefined;
184
186
  const { basePath = '', assetPrefix = '', assetBase = '/_prnext/assets', trailingSlash = false, skipTrailingSlashRedirect = false } = manifest.config || {};
185
187
  const publicPath = assetBase + '/';
186
- const routes = project.routes.filter(route => route.router === 'app');
188
+ const lazy=dev&&lazyDynamicImports?createLazyDynamicImports({root:project.root,stage,manifest,request:devLazyRequest,activated:devLazyActivated}):undefined;
189
+ const routes = devLazyOnly?[]:project.routes.filter(route => route.router === 'app');
187
190
  const htmlOnly = dev ? new Set() : selectHtmlOnlyRoutes(project.routes, manifest.config.htmlOnly, manifest.config);
188
191
  const htmlGraph = !dev && htmlOnly.size ? createHtmlOnlyGraph(project.root) : undefined;
189
- if (project.appNotFound) {
192
+ if (project.appNotFound&&!devLazyOnly) {
190
193
  const file = path.join(stage, '.entries', 'app-not-found.jsx');
191
194
  await writeFile(file, `import {notFound} from 'next/navigation';export default function MissingRoute(){notFound()}`);
192
195
  const segments = project.appNotFound.segments.map(segment => ({ ...segment }));
@@ -199,12 +202,12 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
199
202
  routes.push({ ...project.appNotFound, segments, id, file, pattern: '/_not-found', kind: 'page', router: 'app', internal: true });
200
203
  manifest.appNotFound = id;
201
204
  }
202
- if (!routes.length) return;
205
+ if (!routes.length&&!devLazyOnly) return;
203
206
  if (!manifest.config.cacheComponents && routes.some(route => route.cacheConfig?.instant !== undefined || route.pageConfig?.instant !== undefined || route.segments?.some(segment => segment.staticConfig?.instant !== undefined))) throw new Error('instant requires cacheComponents: true.');
204
207
  if (!manifest.config.cacheComponents && routes.some(route => route.cacheConfig?.ensureStatic !== undefined || route.pageConfig?.ensureStatic !== undefined || route.segments?.some(segment => segment.staticConfig?.ensureStatic !== undefined))) throw new Error('ensureStatic requires cacheComponents: true.');
205
- const actions = createServerActions({ projectRoot: project.root });
206
- function actionTransform(source, file, mode, runtime = 'nodejs') {
207
- const result = actions.transform(source, file, mode);
208
+ const actions = createServerActions({ projectRoot: project.root,developmentSession:dev?manifest.devSession:undefined,developmentSecret:dev?devActionSecret:undefined });
209
+ function actionTransform(source, file, mode, runtime = 'nodejs', revisionSource = source) {
210
+ const result = actions.transform(source, file, mode, {revisionSource});
208
211
  if (mode !== 'browser') for (const action of actions.actions.values()) if (action.file === file) {
209
212
  (action.runtimes ||= new Set()).add(runtime);
210
213
  }
@@ -321,7 +324,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
321
324
  }), args.path);
322
325
  const cacheSegment = cacheSegments.get(await canonicalFile(args.path)) || '';
323
326
  const cachedSource = transformCacheComponents(source, args.path, { enabled: manifest.config.cacheComponents, handlers: manifest.config.cacheHandlers, projectRoot: project.root, mode, buildId: manifest.cacheId, segment: cacheSegment });
324
- const actionSource = actionTransform(cachedSource, args.path, mode);
327
+ const actionSource = actionTransform(cachedSource, args.path, mode, 'nodejs', original);
325
328
  const builtin = path.dirname(args.path) === path.join(packageRoot, 'compat') && forcedClient.has(path.basename(args.path));
326
329
  if (rsc && (builtin || clientDirective(source, args.path))) {
327
330
  htmlGraph?.issue(args.path, 'a Client Component or interactive framework component');
@@ -334,8 +337,9 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
334
337
  const references = boundary.exports.map((name, index) => builtin && path.basename(args.path) === 'image.cjs' && name === 'getImageProps' ? `export {getImageProps} from ${JSON.stringify(path.join(packageRoot, 'compat/image-shared.cjs'))};` : `const ref${index}=registerClientReference(function(){throw new Error(${JSON.stringify(`Cannot call client export ${name} from a Server Component (${path.relative(project.root, args.path)}).`)});},${JSON.stringify(boundary.id)},${JSON.stringify(name)});export {ref${index} as ${JSON.stringify(name)}};`);
335
338
  return { contents: `import {registerClientReference} from 'react-server-dom-webpack/server';\n${references.join('\n')}`, loader: 'js', resolveDir: path.dirname(args.path) };
336
339
  }
337
- let contents = transformDynamicImports(actionSource, args.path, { projectRoot: project.root, mode: rsc ? 'rsc' : browser ? 'browser' : 'server' });
338
- if (browser && refresh && !actions.isServerModule(cachedSource,args.path)) contents = await refresh(contents, args.path);
340
+ let contents = transformDynamicImports(actionSource, args.path, { projectRoot: project.root, mode: rsc ? 'rsc' : browser ? 'browser' : 'server',dev });
341
+ if(browser&&lazy)contents=lazy.transform(contents,args.path);
342
+ if (browser && refresh && !actions.isServerModule(cachedSource,args.path,{revisionSource:original})) contents = await refresh(contents, args.path);
339
343
  return { contents, loader: extensions[path.extname(args.path)] || 'jsx', resolveDir: path.dirname(args.path) };
340
344
  });
341
345
  },
@@ -401,7 +405,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
401
405
  return actionTransform(await fonts.transform(await expandActionExports(locations?await locations(compiled,file):compiled, file, async (specifier, importer) => {
402
406
  const resolved = await builder.resolve(specifier, {resolveDir:path.dirname(importer),kind:'import-statement',pluginData:{prnextExportScan:true}});
403
407
  return resolved.errors.length || resolved.external || resolved.namespace !== 'file' ? undefined : resolved.path;
404
- }), file), file, mode, 'edge');
408
+ }), file), file, mode, 'edge', source);
405
409
  } });
406
410
  fonts.record(result.metafile);
407
411
  styleGraph.record(result.metafile);
@@ -448,22 +452,25 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
448
452
  return output;
449
453
  }
450
454
 
451
- async function collectBrowserStyles(browserEntries) {
455
+ async function collectBrowserStyles(browserEntries,force=false) {
452
456
  const revision = `${boundaries.size}:${actions.actions.size}:${nativePackages.version}`;
453
- if (browserStyleRevision === revision) return;
457
+ if (!force&&browserStyleRevision === revision) return;
454
458
  // SSR intentionally erases ssr:false loaders. Discover their dependencies
455
459
  // in one browser graph, then classify styles by reachability from each root.
456
460
  const discovery = await bundle({ ...common, write: false, entryPoints: browserEntries, outdir: path.join(stage, '.browser-styles'),
457
461
  platform: 'browser', format: 'esm', target: ['es2022'], splitting: true,
458
462
  define: { 'process.env': '{}', ...common.define },
459
- plugins: [moduleResolutionPlugin(moduleResolution, project.root), ...(dev ? [devSingletonsPlugin()] : []), images.plugin(), fonts.plugin(), graphPlugin('browser', { omitGlobalStyles: false }), nativePackages.plugin('browser'), cssModules.plugin(true)] });
463
+ plugins: [...(lazy?[lazy.plugin()]:[]),moduleResolutionPlugin(moduleResolution, project.root), ...(dev ? [devSingletonsPlugin()] : []), images.plugin(), fonts.plugin(), graphPlugin('browser', { omitGlobalStyles: false }), nativePackages.plugin('browser'), cssModules.plugin(true)] });
464
+ await lazy?.discover(discovery.metafile);
460
465
  fonts.record(discovery.metafile);
461
466
  styleGraph.record(discovery.metafile);
462
- const entryIds = new Map(await Promise.all(Object.entries(browserEntries).map(async ([id, file]) => [await canonicalFile(file), id])));
467
+ const entryIds = new Map((await Promise.all(Object.entries(browserEntries).map(async ([id, file]) => [[path.resolve(file), id], [await canonicalFile(file), id]]))).flat());
463
468
  const globalRoots = [], normalRoots = [];
464
469
  for (const output of Object.values(discovery.metafile.outputs)) {
465
470
  if (!output.entryPoint) continue;
466
- const id = entryIds.get(await canonicalFile(path.resolve(project.root, output.entryPoint)));
471
+ // Dynamic entry points can contain a query/fragment identity rather
472
+ // than a physical filename. Only configured roots need classification.
473
+ const id = entryIds.get(path.resolve(project.root, output.entryPoint));
467
474
  if (id) (id === globalErrorId ? globalRoots : normalRoots).push(output.entryPoint);
468
475
  }
469
476
  async function collect(roots, target) {
@@ -514,12 +521,13 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
514
521
  const browserRevision = () => `${boundaryRevision()}:${cssFiles.size}:${globalErrorCssFiles.size}:${nativePackages.version}`;
515
522
  // Route Handlers have a Node React environment and must not share RSC chunks
516
523
  // that import registerServerReference or client-reference registration.
517
- await serverBuild(handlerEntries, 'server', 'route');
524
+ if (!devLazyOnly) await serverBuild(handlerEntries, 'server', 'route');
518
525
  for (;;) {
519
526
  // Each graph consumes discoveries from the preceding one. Discovering a
520
527
  // client boundary in RSC is normal: SSR and browser below already include
521
528
  // it. Only discoveries made too late for a graph require another pass.
522
529
  const emittedActions = actionRevision();
530
+ if (!devLazyOnly) {
523
531
  for (const action of actions.actions.values()) {
524
532
  const entry = path.join(stage, '.entries', 'action-' + action.id + '.mjs');
525
533
  await writeFile(entry, `export {${JSON.stringify(action.binding)} as invoke} from ${JSON.stringify(action.file)};`);
@@ -527,8 +535,10 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
527
535
  if (!action.runtimes?.size || action.runtimes.has('nodejs')) entries['action-' + action.id] = entry;
528
536
  }
529
537
  await serverBuild(entries, 'server', 'rsc');
538
+ }
530
539
  const serverNativeVersion = nativePackages.version;
531
540
  const emittedBoundaries = boundaryRevision();
541
+ if (!devLazyOnly) {
532
542
  const ssrEntries = {};
533
543
  const globalSsrEntries = {};
534
544
  for (const boundary of boundaries.values()) {
@@ -539,40 +549,49 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
539
549
  await serverBuild(ssrEntries, 'app-ssr', 'ssr');
540
550
  for (const boundary of boundaries.values()) if (boundary.edge) await edgeAppBuild(path.join(stage, '.entries', boundary.id + '.ssr.mjs'), path.join(stage, 'app-ssr-edge', boundary.id + '.mjs'), 'ssr', boundary.exports.includes('default'));
541
551
  await serverBuild(globalSsrEntries, 'app-ssr', 'ssr', globalErrorCssFiles);
552
+ }
542
553
  const browserEntries = {};
543
554
  for (const boundary of boundaries.values()) {
544
555
  const entry = path.join(stage, '.entries', boundary.id + '.browser.mjs');
545
556
  await writeFile(entry, boundaryEntry(boundary));
546
557
  browserEntries[boundary.id] = entry;
547
558
  }
548
- await collectBrowserStyles(browserEntries);
559
+ if(!devLazyRequest)await collectBrowserStyles(browserEntries);
560
+ let lazyEntries={};
561
+ if(lazy)for(;;){
562
+ const next=await lazy.targetEntries();
563
+ if(Object.keys(next).join(',')===Object.keys(lazyEntries).join(','))break;
564
+ lazyEntries=next;
565
+ await collectBrowserStyles(devLazyRequest?lazyEntries:{...browserEntries,...lazyEntries},true);
566
+ }
549
567
  globalErrorOnlyCss.clear();
550
568
  for (const file of globalErrorCssFiles) if (!cssFiles.has(file)) globalErrorOnlyCss.add(file);
551
569
  const emittedBrowserInputs = browserRevision();
552
- if (routes.some(route => route.kind === 'page')) {
570
+ if (devLazyOnly||routes.some(route => route.kind === 'page')) {
553
571
  let globalErrorCss = [];
554
572
  if (globalErrorCssFiles.size) {
555
573
  const styleEntry = path.join(stage, '.entries', 'app-global-error-style.mjs');
556
574
  await writeFile(styleEntry, [...globalErrorCssFiles].map(file => `import ${JSON.stringify(file)};`).join('\n'));
557
575
  const styles = await bundle({ ...common, entryPoints: { 'app-global-error-style': styleEntry }, outdir: path.join(stage, 'assets'), entryNames: '[name]-[hash]',
558
576
  platform: 'browser', format: 'esm', target: ['es2022'], minify: !dev, sourcemap: dev || productionBrowserSourceMaps,
559
- plugins: [moduleResolutionPlugin(moduleResolution, project.root), images.plugin(), fonts.plugin(), graphPlugin('browser', { omitGlobalStyles: false }), cssModules.plugin(true)] });
577
+ plugins: [moduleResolutionPlugin(moduleResolution, project.root), images.plugin(), fonts.plugin(), graphPlugin('browser', { omitGlobalStyles: false }), cssModules.plugin(true)] }, { role: 'styles' });
560
578
  globalErrorCss = Object.values(styles.metafile.outputs).filter(output => output.cssBundle).map(output => publicPath + path.basename(output.cssBundle));
561
579
  }
562
580
  if (globalErrorId) manifest.app.globalError = { id: globalErrorId, name: 'default', css: globalErrorCss };
563
581
  const runtimeEntry = path.join(stage, '.entries', 'app-runtime.mjs');
564
582
  const htmlOnlyRoutes = !dev && htmlOnly.size ? routes.filter(route => route.kind === 'page' && !route.internal).map(route => ({pattern:route.pattern,...(htmlOnly.has(route.id)?{htmlOnly:true}:{})})) : undefined;
565
583
  const importers = Object.entries(browserEntries).map(([id, file]) => `${JSON.stringify(id)}:()=>import(${JSON.stringify(file)})`);
566
- await writeFile(runtimeEntry, `${dev ? `import ${JSON.stringify(devClientFile)};\n` : ''}\nimport {bootstrapApp} from ${JSON.stringify(path.join(packageRoot, 'runtime/app-client.mjs'))};\nbootstrapApp({clientModules:{${importers.join(',')}},...${JSON.stringify({ basePath, assetPrefix, assetBase, htmlOnlyRoutes, trailingSlash, skipTrailingSlashRedirect, deploymentId: dev ? undefined : manifest.cacheId, strictMode: manifest.config.reactStrictMode !== false, cacheComponents: manifest.config.cacheComponents === true, partialPrefetching: manifest.config.partialPrefetching === true, navigationCache: manifest.config.navigationCache, ...(globalErrorId ? { globalErrorId, globalErrorCss } : {}), ...(dev ? { dev: { buildId: manifest.buildId, clientManifest: manifest.devClient, onDemand:manifest.devOnDemand===true, sequence:manifest.devSequence, session:manifest.devSession, indicator:manifest.devIndicators, ...(manifest.devTools ? {tools:manifest.devTools} : {}) } } : {}) })}});\n`);
567
- const clientEntries = { 'app-runtime': runtimeEntry, ...browserEntries };
584
+ if(!devLazyRequest)await writeFile(runtimeEntry, `${dev ? `import ${JSON.stringify(devClientFile)};\n` : ''}\nimport {bootstrapApp} from ${JSON.stringify(path.join(packageRoot, 'runtime/app-client.mjs'))};\nbootstrapApp({clientModules:{${importers.join(',')}},...${JSON.stringify({ basePath, assetPrefix, assetBase, htmlOnlyRoutes, trailingSlash, skipTrailingSlashRedirect, deploymentId: dev ? undefined : manifest.cacheId, strictMode: manifest.config.reactStrictMode !== false, cacheComponents: manifest.config.cacheComponents === true, partialPrefetching: manifest.config.partialPrefetching === true, navigationCache: manifest.config.navigationCache, ...(globalErrorId ? { globalErrorId, globalErrorCss } : {}), ...(dev ? { dev: { buildId: manifest.buildId, clientManifest: manifest.devClient, onDemand:manifest.devOnDemand===true, sequence:manifest.devSequence, session:manifest.devSession, indicator:manifest.devIndicators, ...(manifest.devTools ? {tools:manifest.devTools} : {}) } } : {}) })}});\n`);
585
+ const clientEntries = devLazyRequest?{...lazyEntries,...lazy.bridgeEntries()}:{ 'app-runtime': runtimeEntry, ...browserEntries,...lazyEntries,...(lazy?lazy.bridgeEntries():{}) };
568
586
  const browser = await bundle({ ...common, entryPoints: clientEntries, outdir: path.join(stage, 'assets'), entryNames: '[name]-[hash]', chunkNames: 'app-chunk-[hash]', platform: 'browser', format: 'esm', target: ['es2022'], splitting: true, minify: !dev, sourcemap: dev || productionBrowserSourceMaps,
569
- define: { 'process.env': '{}', ...common.define, __PRNEXT_CACHE_COMPONENTS__: JSON.stringify(manifest.config.cacheComponents === true) }, plugins: [moduleResolutionPlugin(moduleResolution, project.root), ...(dev ? [devSingletonsPlugin()] : []), images.plugin(), fonts.plugin(), graphPlugin('browser', { omitAllStyles: true }), nativePackages.plugin('browser'), cssModules.plugin(true, { emitStyles: () => false })] });
587
+ define: { 'process.env': '{}', ...common.define, __PRNEXT_CACHE_COMPONENTS__: JSON.stringify(manifest.config.cacheComponents === true) }, plugins: [...(lazy?[lazy.plugin()]:[]),moduleResolutionPlugin(moduleResolution, project.root), ...(dev ? [devSingletonsPlugin()] : []), images.plugin(), fonts.plugin(), graphPlugin('browser', { omitAllStyles: !lazy }), nativePackages.plugin('browser'), cssModules.plugin(true, { emitStyles: () => !!lazy })] });
570
588
  fonts.record(browser.metafile);
571
589
  const entryIds = new Map(Object.entries(clientEntries).map(([id, file]) => [file, id]));
572
590
  const edgeBoundaries = new Set([...boundaries.values()].filter(boundary => boundary.edge).map(boundary => boundary.id));
573
591
  let client;
574
592
  for (const [output, metadata] of Object.entries(browser.metafile.outputs)) {
575
593
  if (!metadata.entryPoint || !output.endsWith('.js')) continue;
594
+ if(lazy?.record(metadata,output))continue;
576
595
  const id = entryIds.get(path.resolve(project.root, metadata.entryPoint));
577
596
  if (id === 'app-runtime') { client = publicPath + path.basename(output); continue; }
578
597
  if (!id) continue;
@@ -593,7 +612,7 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
593
612
  const styleEntry = path.join(stage, '.entries', id + '.mjs');
594
613
  await writeFile(styleEntry, files.map(file => `import ${JSON.stringify(file)};`).join('\n'));
595
614
  const output = await bundle({ ...common, entryPoints: { [id]: styleEntry }, outdir: path.join(stage, 'assets'), entryNames: '[name]-[hash]', platform: 'browser', format: 'esm', target: ['es2022'], minify: !dev, sourcemap: dev || productionBrowserSourceMaps,
596
- plugins: [moduleResolutionPlugin(moduleResolution, project.root), images.plugin(), fonts.plugin(), graphPlugin('browser', { omitGlobalStyles: false }), cssModules.plugin(true)] });
615
+ plugins: [moduleResolutionPlugin(moduleResolution, project.root), images.plugin(), fonts.plugin(), graphPlugin('browser', { omitGlobalStyles: false }), cssModules.plugin(true)] }, { role: 'styles' });
597
616
  styleBundles.set(signature, Object.values(output.metafile.outputs).filter(output => output.cssBundle).map(output => publicPath + path.basename(output.cssBundle)));
598
617
  }
599
618
  const css = styleBundles.get(signature);
@@ -610,10 +629,12 @@ export async function compileApp({ project, stage, manifest, cssModules, fonts,
610
629
  module: `server/action-${action.runtimes?.has('nodejs')?'':'edge-'}${action.id}.mjs`, export: 'invoke',
611
630
  ...(action.runtimes?.has('edge') ? {edgeModule:`server/action-edge-${action.id}.mjs`} : {}),
612
631
  }]));
613
- if (emittedActions === actionRevision() && emittedBoundaries === boundaryRevision() &&
632
+ if ((devLazyOnly || emittedActions === actionRevision() && emittedBoundaries === boundaryRevision()) &&
614
633
  serverNativeVersion === nativePackages.version && emittedBrowserInputs === browserRevision()) break;
615
634
  }
616
635
 
636
+ lazy?.finish();
637
+
617
638
  if (htmlGraph) for (const route of routes) if (htmlOnly.has(route.id)) {
618
639
  for (const file of [...appStyleScopes(route).flat(), ...(route.metadataFiles || []).map(item=>item.file)].filter(Boolean)) htmlGraph.alias(file, await canonicalFile(file));
619
640
  htmlGraph.validate(route);
@@ -145,18 +145,19 @@ export async function validateCompilerConfig(config, root, {dev=false,edge=false
145
145
  const state={config,root,dev,buildId:'prnext-check',targets:new Map()};
146
146
  for (const target of ['node','browser',...(edge?['edge']:[])]) await targetConfig(state,target);
147
147
  }
148
- export async function build(options) {
148
+ export async function build(options, { role = 'main' } = {}) {
149
+ if (role !== 'main' && role !== 'styles') throw new TypeError('Invalid compiler graph role');
149
150
  const state=compilation.getStore();
150
151
  const target=options.define?.['process.env.NEXT_RUNTIME']==='"edge"'?'edge':options.platform==='browser'?'browser':'node';
151
152
  return withReactCompilerGraph(target,async()=>{
152
- const result=await compileGraph(options);
153
+ const result=await compileGraph(options,role);
153
154
  recordReactCompilerGraph(result);
154
155
  recordBuildAnalysis(state,options,result);
155
156
  recordDevDependencies(state,options,result);
156
157
  return result;
157
158
  });
158
159
  }
159
- async function compileGraph(options) {
160
+ async function compileGraph(options,role) {
160
161
  const state = compilation.getStore();
161
162
  // The transform is captured by the framework's per-build callbacks. Give both
162
163
  // esbuild and the webpack bridge a new graph identity when its options change,
@@ -183,7 +184,7 @@ async function compileGraph(options) {
183
184
  // Runtime vendor bundles are framework-owned, not project webpack targets.
184
185
  const projectGraph = (options.plugins || []).some(plugin => framework.test(plugin.name));
185
186
  if (projectGraph && state?.tsconfig) options = { ...options, tsconfig: state.tsconfig };
186
- const nativeCompile = input => state?.incremental && (projectGraph || !input.plugins?.length) ? incrementalBuild(input,{root:state.root,stage:state.stage,group:projectGraph?'project':'auxiliary',run:callback=>compilation.run(state,callback)}) : esbuild(input);
187
+ const nativeCompile = input => state?.incremental && (projectGraph || !input.plugins?.length) ? incrementalBuild(input,{root:state.root,stage:state.stage,group:projectGraph?'project':'auxiliary',role:projectGraph?role:'auxiliary',run:callback=>compilation.run(state,callback)}) : esbuild(input);
187
188
  const compile = input => state?.root && state?.stage ? cachedGraphBuild(input,state,nativeCompile) : nativeCompile(input);
188
189
  if (!(state?.config?.webpack || Object.keys(state?.config?.turbopack?.rules || {}).length) || !projectGraph) return compile(options);
189
190
  const target = options.define?.['process.env.NEXT_RUNTIME'] === '"edge"' ? 'edge' : options.platform === 'browser' ? 'browser' : 'node';
package/build/config.mjs CHANGED
@@ -164,11 +164,13 @@ export function validateProjectConfig(input, filename = 'project config') {
164
164
  if (config.sassOptions !== undefined && (!config.sassOptions || typeof config.sassOptions !== 'object' || Array.isArray(config.sassOptions))) throw new Error(`${filename}: sassOptions must be an object.`);
165
165
  if (!config.experimental || typeof config.experimental !== 'object' || Array.isArray(config.experimental)) throw new Error(`${filename}: experimental must be an object.`);
166
166
  for (const key of Object.keys(config.experimental)) {
167
- if (!['nextScriptWorkers', 'serverActions', 'optimizePackageImports', 'proxyClientMaxBodySize'].includes(key)) throw new Error(`${filename}: experimental.${key} is not supported by PRNext. It cannot be silently ignored.`);
167
+ if (!['nextScriptWorkers', 'serverActions', 'optimizePackageImports', 'proxyClientMaxBodySize', 'lazyDynamicImports'].includes(key)) throw new Error(`${filename}: experimental.${key} is not supported by PRNext. It cannot be silently ignored.`);
168
168
  }
169
169
  const nextScriptWorkers = config.experimental.nextScriptWorkers === undefined ? false : config.experimental.nextScriptWorkers;
170
170
  if (typeof nextScriptWorkers !== 'boolean') throw new Error(`${filename}: experimental.nextScriptWorkers must be a boolean.`);
171
- config.experimental = { nextScriptWorkers, serverActions: validateServerActions(config.experimental.serverActions), optimizePackageImports: validateOptimizePackageImports(config.experimental.optimizePackageImports), proxyClientMaxBodySize: validateProxyBodySize(config.experimental.proxyClientMaxBodySize) };
171
+ const lazyDynamicImports=config.experimental.lazyDynamicImports===undefined?false:config.experimental.lazyDynamicImports;
172
+ if(typeof lazyDynamicImports!=='boolean')throw new Error(`${filename}: experimental.lazyDynamicImports must be a boolean.`);
173
+ config.experimental = { nextScriptWorkers, lazyDynamicImports, serverActions: validateServerActions(config.experimental.serverActions), optimizePackageImports: validateOptimizePackageImports(config.experimental.optimizePackageImports), proxyClientMaxBodySize: validateProxyBodySize(config.experimental.proxyClientMaxBodySize) };
172
174
  for (const key of ['basePath', 'assetPrefix']) {
173
175
  if (typeof config[key] !== 'string' || Buffer.byteLength(config[key]) > 4096) throw new Error(`${filename}: ${key} must be a string of at most 4096 bytes.`);
174
176
  }
@@ -0,0 +1,173 @@
1
+ import { copyFile, link, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { rename } from '../runtime/fs.mjs';
5
+
6
+ export const STYLE_OVERLAY_LIMITS = Object.freeze({ files: 4096, bytes: 64 * 1024 * 1024, catalogueBytes: 4 * 1024 * 1024, references: 16384 });
7
+ const digest = data => createHash('sha256').update(data).digest('hex');
8
+ const filename = name => typeof name === 'string' && name.length > 0 && name.length <= 255 && name !== '.' && name !== '..'
9
+ && !/[\\/\x00-\x1f<>:"|?*#]/.test(name) && path.basename(name) === name;
10
+ const inside = (root, file) => { const relative = path.relative(root, file); return relative !== '' && !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep); };
11
+
12
+ export class StyleOverlayFallback extends Error {
13
+ constructor(code) { super(code); this.code = code; }
14
+ }
15
+ const fallback = code => { throw new StyleOverlayFallback(code); };
16
+
17
+ function assetsFor(bundles) {
18
+ const assets = new Map();
19
+ let references = 0, bytes = 0;
20
+ for (const files of bundles.values()) for (const file of files) {
21
+ if (++references > STYLE_OVERLAY_LIMITS.references) fallback('style-overlay-budget');
22
+ if (!file || !filename(file.name) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > STYLE_OVERLAY_LIMITS.bytes || !/^[a-f0-9]{64}$/.test(file.sha256)) fallback('invalid-style-overlay');
23
+ const previous = assets.get(file.name);
24
+ if (previous && (previous.bytes !== file.bytes || previous.sha256 !== file.sha256)) fallback('conflicting-style-overlay');
25
+ if (!previous) {
26
+ assets.set(file.name, file);
27
+ bytes += file.bytes;
28
+ if (assets.size > STYLE_OVERLAY_LIMITS.files || bytes > STYLE_OVERLAY_LIMITS.bytes) fallback('style-overlay-budget');
29
+ }
30
+ }
31
+ return assets;
32
+ }
33
+
34
+ function catalogue(previous, plans) {
35
+ const bundles = new Map();
36
+ if (!previous) return bundles;
37
+ if (previous.version !== 1 || typeof previous.baseDirectory !== 'string' || previous.baseDirectory.length > 4096
38
+ || !Array.isArray(previous.bundles) || previous.bundles.length > STYLE_OVERLAY_LIMITS.files) fallback('invalid-style-overlay');
39
+ let references = 0;
40
+ for (const entry of previous.bundles) {
41
+ if (!Array.isArray(entry) || entry.length !== 2 || !plans.has(entry[0]) || bundles.has(entry[0]) || !Array.isArray(entry[1]) || entry[1].length > STYLE_OVERLAY_LIMITS.files) fallback('invalid-style-overlay');
42
+ references += entry[1].length;
43
+ if (references > STYLE_OVERLAY_LIMITS.references) fallback('style-overlay-budget');
44
+ if (!entry[1].some(file => file?.name === entry[0])) fallback('invalid-style-overlay');
45
+ bundles.set(entry[0], entry[1]);
46
+ }
47
+ assetsFor(bundles);
48
+ if (Buffer.byteLength(JSON.stringify(previous)) > STYLE_OVERLAY_LIMITS.catalogueBytes) fallback('style-overlay-budget');
49
+ return bundles;
50
+ }
51
+
52
+ async function inspect(file) {
53
+ const info = await lstat(file);
54
+ if (!info.isFile() || info.size > STYLE_OVERLAY_LIMITS.bytes) fallback('invalid-style-overlay');
55
+ const data = await readFile(file);
56
+ return { name: path.basename(file), bytes: data.byteLength, sha256: digest(data) };
57
+ }
58
+
59
+ /** A CSS generation owns only explicitly catalogued stylesheet assets. */
60
+ export async function createStyleOverlay({ root, source, destination, baseSource = source, manifest, plans, selected }) {
61
+ const previous = manifest.devStyles.overlay;
62
+ if (!previous && manifest.devStyles.baseClient) fallback('missing-style-overlay');
63
+ if (previous && !manifest.devStyles.baseClient) fallback('invalid-style-overlay');
64
+ if (!Array.isArray(plans) || plans.length > STYLE_OVERLAY_LIMITS.files) fallback('style-overlay-budget');
65
+ const names = new Set(plans.map(([name]) => name));
66
+ if (names.size !== plans.length || ![...names].every(filename)) fallback('invalid-style-overlay');
67
+ const bundles = catalogue(previous, names), previousAssets = assetsFor(bundles);
68
+ const selectedNames = new Set(selected.map(([name]) => name));
69
+ if ([...selectedNames].some(name => !names.has(name))) fallback('invalid-style-overlay');
70
+ for (const route of manifest.routes || []) for (const url of route.css || []) {
71
+ if (url.includes('?v=') && !bundles.has(path.posix.basename(url.split('?')[0]))) fallback('missing-style-overlay');
72
+ }
73
+ const [canonicalRoot, canonicalSource, canonicalBase, parent, sourceAssets] = await Promise.all([realpath(root), realpath(source), realpath(baseSource), realpath(path.dirname(destination)), realpath(path.join(source, 'assets'))]).catch(error => {
74
+ if (error.code === 'ENOENT') fallback('missing-style-overlay');
75
+ throw error;
76
+ });
77
+ // The destination does not exist yet. Its existing parent supplies the real
78
+ // identity, including macOS /var aliases and Windows short-name prefixes.
79
+ const canonicalDestination = path.join(parent, path.basename(destination));
80
+ if (!inside(canonicalRoot, canonicalSource) || !inside(canonicalRoot, canonicalBase)
81
+ || parent !== canonicalRoot && !inside(canonicalRoot, parent) || !inside(canonicalRoot, canonicalDestination)
82
+ || canonicalDestination === canonicalSource || canonicalDestination === canonicalBase
83
+ || inside(canonicalDestination, canonicalSource) || inside(canonicalSource, canonicalDestination)
84
+ || inside(canonicalDestination, canonicalBase) || inside(canonicalBase, canonicalDestination)
85
+ || sourceAssets !== path.join(canonicalSource, 'assets')) fallback('invalid-style-overlay-directory');
86
+ const baseDirectory = path.relative(canonicalRoot, canonicalBase).replaceAll(path.sep, '/');
87
+ if (previous ? previous.baseDirectory !== baseDirectory : canonicalSource !== canonicalBase) fallback('invalid-style-overlay-base');
88
+ const assetsDirectory = path.join(destination, 'assets');
89
+ let owned = false;
90
+ async function inherit(file) {
91
+ const from = path.join(source, 'assets', file.name), target = path.join(assetsDirectory, file.name);
92
+ let actual;
93
+ try { actual = await inspect(from); } catch (error) { if (error.code === 'ENOENT') fallback('missing-style-overlay-asset'); throw error; }
94
+ if (actual.bytes !== file.bytes || actual.sha256 !== file.sha256) fallback('invalid-style-overlay-asset');
95
+ try { await link(from, target); }
96
+ catch (error) {
97
+ if (!['EXDEV', 'EPERM', 'ENOTSUP', 'EACCES'].includes(error.code)) throw error;
98
+ await copyFile(from, target);
99
+ }
100
+ }
101
+ try {
102
+ await mkdir(destination);
103
+ owned = true;
104
+ await mkdir(assetsDirectory);
105
+ for (const name of selected.map(([name]) => name)) bundles.delete(name);
106
+ for (const file of assetsFor(bundles).values()) await inherit(file);
107
+ } catch (error) {
108
+ if (owned) await rm(destination, { recursive: true, force: true });
109
+ throw error;
110
+ }
111
+
112
+ return {
113
+ async recordBundle(name, result) {
114
+ if (!selectedNames.has(name)) fallback('invalid-style-overlay-output');
115
+ const files = new Map();
116
+ for (const output of result.outputFiles) {
117
+ if (path.dirname(path.resolve(output.path)) !== path.resolve(assetsDirectory) || !filename(path.basename(output.path))) fallback('invalid-style-overlay-output');
118
+ const file = { name: path.basename(output.path), bytes: output.contents.byteLength, sha256: digest(output.contents) };
119
+ files.set(file.name, file);
120
+ }
121
+ if (!files.has(name)) fallback('invalid-style-overlay-output');
122
+ // CSS Module compilation emits its URL assets before the outer CSS
123
+ // bundle. Its absolute, external URL-token imports identify exactly the
124
+ // emitted images/fonts to retain, without scanning the JavaScript build.
125
+ const prefix = manifest.config.assetBase.replace(/\/$/, '') + '/';
126
+ for (const output of Object.values(result.metafile.outputs)) for (const item of output.imports || []) {
127
+ if (item.kind !== 'url-token' || !item.external || !item.path.startsWith(prefix)) continue;
128
+ let name;
129
+ try { name = decodeURIComponent(item.path.slice(prefix.length).split(/[?#]/)[0]); }
130
+ catch { fallback('invalid-style-overlay-output'); }
131
+ if (!filename(name) || files.has(name)) continue;
132
+ let file;
133
+ try { file = await inspect(path.join(assetsDirectory, name)); }
134
+ catch (error) {
135
+ if (error.code !== 'ENOENT') throw error;
136
+ const previous = previousAssets.get(name);
137
+ if (!previous) continue; // An unchanged base or public asset stays in its original generation.
138
+ await inherit(previous);
139
+ file = previous;
140
+ }
141
+ files.set(name, file);
142
+ }
143
+ bundles.set(name, [...files.values()]);
144
+ assetsFor(bundles); // Validate the aggregate budget before publishing bytes.
145
+ for (const output of result.outputFiles) {
146
+ const file = files.get(path.basename(output.path));
147
+ let existing;
148
+ try { existing = await inspect(output.path); } catch (error) { if (error.code !== 'ENOENT') throw error; }
149
+ if (existing) {
150
+ if (existing.bytes !== file.bytes || existing.sha256 !== file.sha256) fallback('conflicting-style-overlay');
151
+ continue;
152
+ }
153
+ const temporary = output.path + '.' + randomUUID() + '.tmp';
154
+ try { await writeFile(temporary, output.contents); await rename(temporary, output.path); }
155
+ finally { await rm(temporary, { force: true }); }
156
+ }
157
+ },
158
+ async finish() {
159
+ const assets = assetsFor(bundles);
160
+ const value = { version: 1, baseDirectory, bundles: [...bundles] };
161
+ if (Buffer.byteLength(JSON.stringify(value)) > STYLE_OVERLAY_LIMITS.catalogueBytes) fallback('style-overlay-budget');
162
+ // This directory was created above for CSS only. Remove module assets
163
+ // that no current bundle references, rather than accumulating old URLs.
164
+ const entries = await readdir(assetsDirectory, { withFileTypes: true });
165
+ if (entries.length > STYLE_OVERLAY_LIMITS.references) fallback('style-overlay-budget');
166
+ for (const entry of entries) {
167
+ if (!entry.isFile() || !filename(entry.name)) fallback('invalid-style-overlay-output');
168
+ if (!assets.has(entry.name)) await rm(path.join(assetsDirectory, entry.name));
169
+ }
170
+ return value;
171
+ },
172
+ };
173
+ }
@@ -1,14 +1,15 @@
1
1
  import {build as bundle} from 'esbuild';
2
- import {readFile,writeFile,rm,stat,readdir} from 'node:fs/promises';
2
+ import {readFile,writeFile,rm,stat} from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import {randomUUID} from 'node:crypto';
5
5
  import {rename} from '../runtime/fs.mjs';
6
6
  import {createCssModules} from './css-modules.mjs';
7
7
  import {recordDevDependencies,developmentStyleDependencies} from './dev-dependencies.mjs';
8
8
  import {writeBuildOutput} from './output-contract.mjs';
9
+ import {createStyleOverlay,StyleOverlayFallback} from './dev-css-assets.mjs';
9
10
 
10
11
  /** Publish only styles when all JS-visible class maps remain identical. */
11
- export async function tryDevCssUpdate({root,source,destination,manifest,changed,sequence,cssEngine,snapshot,onFallback=()=>{}}){
12
+ export async function tryDevCssUpdate({root,source,baseSource=source,destination,manifest,changed,sequence,cssEngine,onFallback=()=>{}}){
12
13
  const fallback=code=>{onFallback(code);return undefined;};
13
14
  if(!changed.length||changed.some(file=>!file.endsWith('.css'))||!manifest.devStyles?.bundles.length||manifest.app?.globalError?.css?.length)return;
14
15
  if(!manifest.config.assetBase.startsWith('/'))return fallback('external-asset-base');
@@ -32,8 +33,9 @@ export async function tryDevCssUpdate({root,source,destination,manifest,changed,
32
33
  if(changed.some(file=>!known.has(path.join(root,file))))return fallback('unknown-style-dependency');
33
34
  if(!selected.length)return fallback('unknown-style-dependency');
34
35
  const stage=destination,modules=createCssModules({projectRoot:root,stage,assetBase:manifest.config.assetBase,cssEngine,dev:true});
36
+ let overlay;
35
37
  try{
36
- await snapshot(source,stage);
38
+ overlay=await createStyleOverlay({root,source,baseSource,destination,manifest,plans,selected});
37
39
  for(const previous of manifest.devStyles.modules.filter(module=>(module.watchFiles||[module.file]).some(file=>changedPaths.has(file)))){
38
40
  if(!previous.file.endsWith('.css')){await rm(stage,{recursive:true,force:true});return fallback('stylesheet-language');}
39
41
  if((await modules.compile(previous.file)).mapping!==previous.mapping){await rm(stage,{recursive:true,force:true});return fallback('css-module-exports');}
@@ -42,7 +44,7 @@ export async function tryDevCssUpdate({root,source,destination,manifest,changed,
42
44
  for(const [name,files]of selected){
43
45
  const options={absWorkingDir:root,stdin:{contents:files.map(file=>`@import ${JSON.stringify(file.replace(/^prnext-compiled-css:/,'prnext-css:'))};`).join('\n'),resolveDir:root,loader:'css'},outfile:path.join(stage,'assets',name),bundle:true,write:false,metafile:true,logLevel:'silent',publicPath:manifest.config.assetBase,assetNames:'[name]-[hash]',loader:Object.fromEntries(['.png','.jpg','.jpeg','.svg','.gif','.webp','.avif','.woff','.woff2','.ttf'].map(ext=>[ext,'file'])),plugins:[modules.plugin(true)]};
44
46
  const result=await bundle(options);recordDevDependencies(state,options,result);
45
- for(const output of result.outputFiles){const temporary=output.path+'.'+randomUUID()+'.tmp';try{await writeFile(temporary,output.contents);await rename(temporary,output.path);}finally{await rm(temporary,{force:true});}}
47
+ await overlay.recordBundle(name,result);
46
48
  }
47
49
  const compiled=await modules.mappings();
48
50
  if(compiled.some(value=>!manifest.devStyles.modules.some(previous=>previous.file===value.file&&previous.mapping===value.mapping))){await rm(stage,{recursive:true,force:true});return fallback('css-module-exports');}
@@ -51,7 +53,8 @@ export async function tryDevCssUpdate({root,source,destination,manifest,changed,
51
53
  for(const module of updated)if(files.includes(module.file))for(const file of module.watchFiles)if(!files.includes(file))files.push(file);
52
54
  dependencies.set(name,files);
53
55
  }
54
- const buildId=randomUUID(),baseClient=manifest.devStyles.baseClient||manifest.devClient,next={...manifest,buildId,devSequence:sequence,devClient:manifest.config.assetBase+'/dev-manifest-'+buildId+'.json',outputDirectory:stage,devStyles:{baseClient,bundles:plans,dependencies:[...dependencies],modules:updated}};
56
+ const assets=await overlay.finish();
57
+ const buildId=randomUUID(),baseClient=manifest.devStyles.baseClient||manifest.devClient,next={...manifest,buildId,devSequence:sequence,devClient:manifest.config.assetBase+'/dev-manifest-'+buildId+'.json',outputDirectory:stage,devStyles:{baseClient,overlay:assets,bundles:plans,dependencies:[...dependencies],modules:updated}};
55
58
  // Keep route invalidation conservative as CSS imports are added or removed.
56
59
  // Unchanged routes retain their original dependency arrays.
57
60
  next.devDependencies=(manifest.devDependencies||[]).map(route=>{
@@ -66,7 +69,10 @@ export async function tryDevCssUpdate({root,source,destination,manifest,changed,
66
69
  }
67
70
  return {...route,files:[...files]};
68
71
  });
69
- const old=JSON.parse(await readFile(path.join(source,'assets',path.basename(manifest.devClient)),'utf8'));
72
+ let old;
73
+ try{old=JSON.parse(await readFile(path.join(source,'assets',path.basename(manifest.devClient)),'utf8'));}
74
+ catch(error){if(error.code==='ENOENT'||error instanceof SyntaxError)throw new StyleOverlayFallback('missing-style-manifest');throw error;}
75
+ if(!Array.isArray(old?.pages)||old.pages.some(page=>!Array.isArray(page.css))||old.app&&(!Array.isArray(old.app.css)||!Array.isArray(old.app.routes)||old.app.routes.some(route=>!Array.isArray(route.css))))throw new StyleOverlayFallback('invalid-style-manifest');
70
76
  old.buildId=buildId;
71
77
  const changedNames=new Set(selected.map(([name])=>name));
72
78
  const revise=urls=>urls.map(url=>changedNames.has(path.posix.basename(url.split('?')[0]))?url.split('?')[0]+'?v='+buildId:url);
@@ -74,12 +80,8 @@ export async function tryDevCssUpdate({root,source,destination,manifest,changed,
74
80
  for(const page of old.pages)page.css=revise(page.css);if(old.app){old.app.css=revise(old.app.css);for(const route of old.app.routes||[]){route.css=revise(route.css);if(route.cssScopes)route.cssScopes=reviseScopes(route.cssScopes);}}
75
81
  next.routes=next.routes.map(route=>({...route,css:revise(route.css||[]),...(route.cssScopes?{cssScopes:reviseScopes(route.cssScopes)}:{})}));
76
82
  await writeFile(path.join(stage,'assets',path.basename(next.devClient)),JSON.stringify(old));
77
- const protectedNames=new Set([path.basename(baseClient),path.basename(next.devClient)]),history=[];
78
- for(const name of await readdir(path.join(stage,'assets')))if(/^dev-manifest-.*\.json$/.test(name)&&!protectedNames.has(name)){const info=await stat(path.join(stage,'assets',name));history.push({name,size:info.size,time:info.mtimeMs});}
79
- history.sort((a,b)=>b.time-a.time);let bytes=0;
80
- for(let i=0;i<history.length;i++){bytes+=history[i].size;if(i>=32||bytes>4*1024*1024)await rm(path.join(stage,'assets',history[i].name));}
81
83
  const temporary=path.join(stage,'manifest.'+buildId+'.tmp');await writeFile(temporary,JSON.stringify(next));await rename(temporary,path.join(stage,'manifest.json'));
82
- await rm(path.join(stage,'build-output.json'));await writeBuildOutput(stage,next);
84
+ await writeBuildOutput(stage,next);
83
85
  return next;
84
- }catch(error){await rm(stage,{recursive:true,force:true});throw error;}
86
+ }catch(error){if(overlay)await rm(stage,{recursive:true,force:true});if(error instanceof StyleOverlayFallback)return fallback(error.code);throw error;}
85
87
  }