@thomas.f/prnext 0.1.18-alpha → 0.1.19-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/build/actions.mjs +26 -14
- package/build/app.mjs +46 -25
- package/build/compiler.mjs +5 -4
- package/build/config.mjs +4 -2
- package/build/dev-css-assets.mjs +173 -0
- package/build/dev-css-update.mjs +14 -12
- package/build/dev-lazy-build.mjs +77 -0
- package/build/dynamic.mjs +13 -6
- package/build/incremental.mjs +112 -18
- package/build/index.mjs +32 -3
- package/build/lazy-dynamic-imports.mjs +137 -0
- package/build/migrate.mjs +4 -4
- package/build/migration-audit.mjs +19 -31
- package/build/migration-imports.mjs +245 -0
- package/build/migration-scenarios-runner.mjs +73 -0
- package/build/migration-scenarios.mjs +72 -0
- package/build/native-discovery.mjs +65 -5
- package/build/native-packages.mjs +2 -0
- package/build/npm-packages.mjs +1 -0
- package/build/production-builder.mjs +1 -1
- package/build/standalone.mjs +21 -3
- package/build/webpack-graph.mjs +13 -3
- package/cli.mjs +10 -1
- package/compat/app-dynamic.cjs +12 -1
- package/compat/cache-handlers.cjs +3 -1
- package/compat/flight-delta.cjs +3 -1
- package/compat/index.d.ts +1 -1
- package/compat/link.cjs +2 -2
- package/docs/ai/compatibility.md +77 -0
- package/docs/ai/routing.md +11 -0
- package/docs/ai/troubleshooting.md +79 -0
- package/package.json +21 -9
- package/runtime/app-client.mjs +119 -42
- package/runtime/atomic-file.mjs +27 -0
- package/runtime/cache-components-rsc.mjs +3 -1
- package/runtime/dev-client.mjs +7 -2
- package/runtime/dev-file-changes.mjs +123 -8
- package/runtime/dev-lazy-imports.mjs +121 -0
- package/runtime/dev-lazy-staging.mjs +14 -0
- package/runtime/dev-style-retention.mjs +15 -0
- package/runtime/development.mjs +60 -19
- package/runtime/flight-delta.mjs +28 -4
- package/runtime/fs.mjs +15 -8
- package/runtime/lazy-import-client.mjs +38 -0
- package/vendor/nft/LICENSE +7 -0
- package/vendor/nft/README.md +16 -0
- package/vendor/nft/out/analyze.d.ts +8 -0
- package/vendor/nft/out/analyze.js +1216 -0
- package/vendor/nft/out/analyze.js.map +1 -0
- package/vendor/nft/out/cli.d.ts +2 -0
- package/vendor/nft/out/cli.js +94 -0
- package/vendor/nft/out/cli.js.map +1 -0
- package/vendor/nft/out/fs.d.ts +22 -0
- package/vendor/nft/out/fs.js +106 -0
- package/vendor/nft/out/fs.js.map +1 -0
- package/vendor/nft/out/index.d.ts +4 -0
- package/vendor/nft/out/index.js +25 -0
- package/vendor/nft/out/index.js.map +1 -0
- package/vendor/nft/out/node-file-trace.d.ts +41 -0
- package/vendor/nft/out/node-file-trace.js +380 -0
- package/vendor/nft/out/node-file-trace.js.map +1 -0
- package/vendor/nft/out/resolve-dependency.d.ts +6 -0
- package/vendor/nft/out/resolve-dependency.js +427 -0
- package/vendor/nft/out/resolve-dependency.js.map +1 -0
- package/vendor/nft/out/types.d.ts +66 -0
- package/vendor/nft/out/types.js +2 -0
- package/vendor/nft/out/types.js.map +1 -0
- package/vendor/nft/out/utils/ast-helpers.js +49 -0
- package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
- package/vendor/nft/out/utils/binary-locators.js +88 -0
- package/vendor/nft/out/utils/binary-locators.js.map +1 -0
- package/vendor/nft/out/utils/get-package-base.js +30 -0
- package/vendor/nft/out/utils/get-package-base.js.map +1 -0
- package/vendor/nft/out/utils/interop-require.js +24 -0
- package/vendor/nft/out/utils/interop-require.js.map +1 -0
- package/vendor/nft/out/utils/node-version.js +10 -0
- package/vendor/nft/out/utils/node-version.js.map +1 -0
- package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
- package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
- package/vendor/nft/out/utils/special-cases.js +347 -0
- package/vendor/nft/out/utils/special-cases.js.map +1 -0
- package/vendor/nft/out/utils/static-eval.js +579 -0
- package/vendor/nft/out/utils/static-eval.js.map +1 -0
- package/vendor/nft/out/utils/types.js +2 -0
- package/vendor/nft/out/utils/types.js.map +1 -0
- package/vendor/nft/out/utils/wrappers.js +659 -0
- package/vendor/nft/out/utils/wrappers.js.map +1 -0
- package/vendor/nft/package.json +4 -0
- package/vendor/nft/upstream.json +51 -0
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
|
-
|
|
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
|
|
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
|
|
207
|
-
|
|
208
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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:
|
|
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);
|
package/build/compiler.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/build/dev-css-update.mjs
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
import {build as bundle} from 'esbuild';
|
|
2
|
-
import {readFile,writeFile,rm,stat
|
|
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,
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
}
|