@thomas.f/prnext 0.1.18-alpha → 0.1.20-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/build/actions.mjs +26 -14
- package/build/app.mjs +90 -101
- package/build/build-lock.mjs +2 -71
- package/build/build-retention.mjs +119 -0
- package/build/bundle-runtime.mjs +9 -8
- package/build/cache-command.mjs +31 -0
- package/build/check.mjs +41 -3
- package/build/compilation-cache.mjs +32 -12
- package/build/compilation-storage.mjs +190 -0
- package/build/compiler.mjs +8 -7
- package/build/config.mjs +25 -2
- package/build/create.mjs +1 -1
- package/build/css-modules.mjs +4 -0
- package/build/dev-action-graph.mjs +21 -0
- 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/font-cache.mjs +9 -6
- package/build/font-files.mjs +1 -1
- package/build/incremental.mjs +112 -18
- package/build/index.mjs +61 -15
- 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/native-ppr.mjs +32 -0
- package/build/npm-packages.mjs +1 -0
- package/build/og.mjs +121 -1
- package/build/parallel-plan.mjs +55 -0
- package/build/persistent-cache.mjs +29 -24
- package/build/production-builder.mjs +1 -1
- package/build/production-check.mjs +34 -0
- package/build/react-compiler-report.mjs +31 -8
- package/build/react-compiler.mjs +101 -10
- package/build/server-config.mjs +35 -0
- package/build/source-analysis.mjs +54 -4
- package/build/standalone.mjs +21 -3
- package/build/static-adapter.mjs +7 -3
- package/build/transform.mjs +38 -6
- package/build/typescript-dev.mjs +3 -1
- package/build/webpack-graph.mjs +13 -3
- package/cli.mjs +24 -6
- package/compat/app-dynamic.cjs +12 -1
- package/compat/app-layout-context.cjs +7 -3
- package/compat/cache-handlers.cjs +3 -1
- package/compat/flight-delta.cjs +50 -13
- package/compat/index.d.ts +24 -1
- package/compat/link.cjs +2 -2
- package/compat/navigation-authorization.cjs +8 -1
- package/docs/ai/compatibility.md +187 -7
- package/docs/ai/deployment.md +141 -0
- package/docs/ai/quickstart.md +5 -0
- package/docs/ai/routing.md +11 -0
- package/docs/ai/troubleshooting.md +79 -0
- package/package.json +26 -11
- package/release/fontkit.mjs +31 -0
- package/release/verify.mjs +3 -0
- package/runtime/api.mjs +5 -2
- package/runtime/app-client.mjs +254 -53
- package/runtime/app-partial.mjs +2 -10
- package/runtime/app-prefetch.mjs +2 -2
- package/runtime/app-render.mjs +11 -7
- package/runtime/app-stream-channel.mjs +3 -2
- package/runtime/app-styles.mjs +22 -4
- package/runtime/atomic-file.mjs +27 -0
- package/runtime/cache-components-rsc.mjs +3 -1
- package/runtime/compile-cache-retention.mjs +53 -0
- package/runtime/compile-cache.mjs +28 -1
- package/runtime/dev-client.mjs +13 -5
- package/runtime/dev-file-changes.mjs +123 -8
- package/runtime/dev-gateway.mjs +3 -2
- package/runtime/dev-lazy-imports.mjs +121 -0
- package/runtime/dev-lazy-staging.mjs +14 -0
- package/runtime/dev-route-selection.mjs +64 -0
- package/runtime/dev-runtime-pool.mjs +3 -2
- package/runtime/dev-runtime-spare.mjs +1 -1
- package/runtime/dev-session-retention.mjs +154 -0
- package/runtime/dev-style-retention.mjs +15 -0
- package/runtime/development.mjs +86 -26
- package/runtime/env.mjs +9 -0
- package/runtime/flight-delta.mjs +59 -9
- package/runtime/fs.mjs +15 -8
- package/runtime/lazy-import-client.mjs +38 -0
- package/runtime/native-ppr.mjs +35 -0
- package/runtime/og/NOTICE.txt +1 -1
- package/runtime/og/index.node.mjs +76 -31216
- package/runtime/og/upstream.json +115 -0
- package/runtime/persistent/client.mjs +4 -2
- package/runtime/persistent/daemon.mjs +3 -11
- package/runtime/persistent/health.mjs +58 -0
- package/runtime/project-lock.mjs +71 -0
- package/runtime/request-transport.mjs +112 -32
- package/runtime/storage-path.mjs +36 -0
- package/runtime/trace.mjs +44 -4
- package/runtime/worker.mjs +9 -4
- package/vendor/fontkit/NOTICE.txt +5 -0
- package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
- package/vendor/fontkit/index.mjs +3 -0
- package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
- package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
- package/vendor/fontkit/licenses/brotli/readme.md +61 -0
- package/vendor/fontkit/licenses/clone/LICENSE +18 -0
- package/vendor/fontkit/licenses/dfa/README.md +83 -0
- package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
- package/vendor/fontkit/licenses/fontkit/README.md +266 -0
- package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
- package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
- package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
- package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
- package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
- package/vendor/fontkit/upstream.json +215 -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
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Recovery journal for framework-owned build stages. Uncommitted backups can
|
|
2
|
+
// be the only working build after a crash: keep them for explicit recovery.
|
|
3
|
+
import {mkdir,open,opendir,lstat,rename,unlink,rm,realpath} from 'node:fs/promises';
|
|
4
|
+
import {constants} from 'node:fs';
|
|
5
|
+
import {randomUUID} from 'node:crypto';
|
|
6
|
+
import {hostname} from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import {captureStorageDirectory,verifyStorageDirectories,sameStoragePath} from '../runtime/storage-path.mjs';
|
|
9
|
+
const uuid='[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}';
|
|
10
|
+
const recordName=new RegExp('^'+uuid+'\\.json$'),stageName=new RegExp('^\\.prnext-build-'+uuid+'$'),backupName=new RegExp('^\\.prnext-backup-'+uuid+'$');
|
|
11
|
+
const MAX_JOURNAL_NAMES=32768,MAX_PAGE=128;
|
|
12
|
+
async function container(root){
|
|
13
|
+
const directory=path.join(root,'.prnext-gc');
|
|
14
|
+
await mkdir(directory,{mode:0o700}).catch(error=>{if(error.code!=='EEXIST')throw error;});
|
|
15
|
+
const info=await lstat(directory);if(!info.isDirectory()||info.isSymbolicLink())throw Error('Build recovery journal must be a real directory.');
|
|
16
|
+
return directory;
|
|
17
|
+
}
|
|
18
|
+
async function readValue(file){
|
|
19
|
+
let handle;
|
|
20
|
+
try{
|
|
21
|
+
const info=await lstat(file);if(!info.isFile()||info.isSymbolicLink()||info.size>4096)return null;
|
|
22
|
+
handle=await open(file,constants.O_RDONLY|(constants.O_NOFOLLOW||0));if((await handle.stat()).size>4096)return null;
|
|
23
|
+
return JSON.parse(await handle.readFile('utf8'));
|
|
24
|
+
}catch{return null;}finally{await handle?.close().catch(()=>{});}
|
|
25
|
+
}
|
|
26
|
+
async function read(file){
|
|
27
|
+
const value=await readValue(file);
|
|
28
|
+
if(!value||value.version!==1||!Number.isSafeInteger(value.pid)||value.pid<1||typeof value.host!=='string'||!Number.isSafeInteger(value.createdAt)||!stageName.test(value.stage)||!backupName.test(value.backup)||typeof value.committed!=='boolean')return null;
|
|
29
|
+
return value;
|
|
30
|
+
}
|
|
31
|
+
async function save(file,value){
|
|
32
|
+
const temporary=file+'.'+randomUUID()+'.tmp';
|
|
33
|
+
try{const handle=await open(temporary,'wx',0o600);try{await handle.writeFile(JSON.stringify(value));}finally{await handle.close();}await rename(temporary,file);}
|
|
34
|
+
finally{await unlink(temporary).catch(()=>{});}
|
|
35
|
+
}
|
|
36
|
+
function alive(pid){try{process.kill(pid,0);return true;}catch(error){return error.code!=='ESRCH';}}
|
|
37
|
+
async function remove(root,name,pattern){
|
|
38
|
+
if(!pattern.test(name))return false;
|
|
39
|
+
const target=path.resolve(root,name);if(path.dirname(target)!==root)return false;
|
|
40
|
+
const rootGuard=await captureStorageDirectory(root);if(!rootGuard)return false;
|
|
41
|
+
const info=await lstat(target).catch(error=>{if(error.code!=='ENOENT')throw error;});
|
|
42
|
+
if(!info)return 'missing';if(!info.isDirectory()||info.isSymbolicLink())return false;
|
|
43
|
+
const targetGuard=await captureStorageDirectory(target);
|
|
44
|
+
if(!targetGuard||!await verifyStorageDirectories([rootGuard,targetGuard]))return false;
|
|
45
|
+
// Root and basename were checked immediately before this scoped removal.
|
|
46
|
+
await rm(target,{recursive:true,force:true,maxRetries:3,retryDelay:50});return 'removed';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Caller holds withBuildLock for this canonical root. No age-only deletion. */
|
|
50
|
+
export async function reclaimBuildStages(root,{now=Date.now(),graceMs=3600000,maxEntries=MAX_PAGE,maxListingEntries=MAX_JOURNAL_NAMES}={}){
|
|
51
|
+
const report={scanned:0,listed:0,removedStages:0,removedBackups:0,retainedRecoveryBackups:0,scanComplete:true,deferred:false};
|
|
52
|
+
const pageSize=Number.isSafeInteger(maxEntries)&&maxEntries>0?Math.min(maxEntries,MAX_PAGE):MAX_PAGE;
|
|
53
|
+
const listingLimit=Number.isSafeInteger(maxListingEntries)&&maxListingEntries>0?Math.min(maxListingEntries,MAX_JOURNAL_NAMES):MAX_JOURNAL_NAMES;
|
|
54
|
+
try{
|
|
55
|
+
root=path.resolve(root);const rootGuard=await captureStorageDirectory(root);
|
|
56
|
+
if(!rootGuard)return {...report,scanComplete:false,deferred:true,reason:'The build recovery root changed.'};
|
|
57
|
+
const directory=await container(root);
|
|
58
|
+
const guards=[rootGuard,await captureStorageDirectory(directory)];
|
|
59
|
+
if(!await verifyStorageDirectories(guards))return {...report,scanComplete:false,deferred:true,reason:'The build recovery directory changed.'};
|
|
60
|
+
const cursorFile=path.join(directory,'cursor.json');
|
|
61
|
+
const cursorInfo=await lstat(cursorFile).catch(error=>{if(error.code!=='ENOENT')throw error;});
|
|
62
|
+
if(cursorInfo&&(!cursorInfo.isFile()||cursorInfo.isSymbolicLink()))return {...report,scanComplete:false,deferred:true,reason:'The build recovery cursor is not an ordinary file.'};
|
|
63
|
+
const saved=await readValue(cursorFile);
|
|
64
|
+
const after=saved?.version===1&&typeof saved.after==='string'&&recordName.test(saved.after)?saved.after:null;
|
|
65
|
+
const names=[];
|
|
66
|
+
for await(const entry of await opendir(directory)){
|
|
67
|
+
if(report.listed>=listingLimit)return {...report,scanComplete:false,deferred:true,reason:'The build recovery journal listing limit was reached; no stages were removed.'};
|
|
68
|
+
report.listed++;
|
|
69
|
+
if(recordName.test(entry.name))names.push(entry.name);
|
|
70
|
+
}
|
|
71
|
+
names.sort();
|
|
72
|
+
let start=after?names.findIndex(name=>name>after):0;if(start<0)start=0;
|
|
73
|
+
const page=names.slice(start,start+pageSize);
|
|
74
|
+
report.scanComplete=start+page.length>=names.length;
|
|
75
|
+
report.deferred=!report.scanComplete;
|
|
76
|
+
if(report.deferred)report.reason='More build recovery journals remain; the next build resumes this scan.';
|
|
77
|
+
for(const name of page){
|
|
78
|
+
if(!await verifyStorageDirectories(guards))return {...report,scanComplete:false,deferred:true,reason:'The build recovery directory changed.'};
|
|
79
|
+
report.scanned++;
|
|
80
|
+
const file=path.join(directory,name),owner=await read(file);
|
|
81
|
+
if(!owner||owner.host!==hostname()||alive(owner.pid)||now-owner.createdAt<graceMs)continue;
|
|
82
|
+
let stageGone=false,backupGone=false;
|
|
83
|
+
try{stageGone=await remove(root,owner.stage,stageName);if(stageGone==='removed')report.removedStages++;}catch{}
|
|
84
|
+
if(owner.committed){try{backupGone=await remove(root,owner.backup,backupName);if(backupGone==='removed')report.removedBackups++;}catch{}}
|
|
85
|
+
else{
|
|
86
|
+
const backup=await lstat(path.join(root,owner.backup)).catch(()=>null);
|
|
87
|
+
backupGone=!backup;if(backup)report.retainedRecoveryBackups++;
|
|
88
|
+
}
|
|
89
|
+
if(stageGone&&backupGone&&await verifyStorageDirectories(guards))await unlink(file).catch(()=>{});
|
|
90
|
+
}
|
|
91
|
+
if(!await verifyStorageDirectories(guards))return {...report,scanComplete:false,deferred:true,reason:'The build recovery directory changed.'};
|
|
92
|
+
// The caller owns the publisher lock, so a single persistent cursor advances
|
|
93
|
+
// across fresh CLI processes without renaming any live owner's journals.
|
|
94
|
+
await save(cursorFile,{version:1,after:report.scanComplete?null:page.at(-1)||null});
|
|
95
|
+
}catch{report.scanComplete=false;report.deferred=true;report.reason='Build recovery maintenance is unavailable; retry on a later build.';}
|
|
96
|
+
return report;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function trackBuildStage(root,{stage,backup}){
|
|
100
|
+
root=path.resolve(root);const rootGuard=await captureStorageDirectory(root);
|
|
101
|
+
if(!rootGuard)throw Error('Build recovery root must be a canonical physical directory.');
|
|
102
|
+
const value={version:1,pid:process.pid,host:hostname(),createdAt:Date.now(),stage:path.basename(stage),backup:path.basename(backup),committed:false};
|
|
103
|
+
if(!stageName.test(value.stage)||!backupName.test(value.backup)||!sameStoragePath(await realpath(path.dirname(path.resolve(stage))),root)||!sameStoragePath(await realpath(path.dirname(path.resolve(backup))),root))throw Error('Build recovery paths must be owned stages inside the project.');
|
|
104
|
+
const file=path.join(await container(root),randomUUID()+'.json');
|
|
105
|
+
const guards=[rootGuard,await captureStorageDirectory(path.dirname(file))];
|
|
106
|
+
if(!await verifyStorageDirectories(guards))throw Error('Build recovery directory changed before publication.');
|
|
107
|
+
await save(file,value);
|
|
108
|
+
return {
|
|
109
|
+
async committed(){value.committed=true;if(await verifyStorageDirectories(guards))await save(file,value);},
|
|
110
|
+
async cleanup(){
|
|
111
|
+
try{
|
|
112
|
+
const stageGone=await remove(root,value.stage,stageName);
|
|
113
|
+
// An uncommitted backup is kept until rollback has restored it.
|
|
114
|
+
const backupGone=value.committed?await remove(root,value.backup,backupName):!await lstat(path.join(root,value.backup)).catch(()=>null);
|
|
115
|
+
if(stageGone&&backupGone&&await verifyStorageDirectories(guards))await unlink(file);
|
|
116
|
+
}catch{/* Keep the journal so a later build can retry Windows busy files. */}
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
}
|
package/build/bundle-runtime.mjs
CHANGED
|
@@ -15,7 +15,7 @@ export const runtimeEntryNames = Object.freeze([
|
|
|
15
15
|
].sort());
|
|
16
16
|
|
|
17
17
|
/** Run once, after compiling application/Edge graphs and before any prerender. */
|
|
18
|
-
export async function bundleRuntime(stage,{projectRoot,sourceDirectory}={}) {
|
|
18
|
+
export async function bundleRuntime(stage,{projectRoot,sourceDirectory,imageResponse=true}={}) {
|
|
19
19
|
const resolvedStage = path.resolve(stage);
|
|
20
20
|
if (!path.basename(resolvedStage).startsWith('.prnext-build-')) {
|
|
21
21
|
throw new Error('Runtime bundling requires an unpublished PRNext build stage.');
|
|
@@ -35,7 +35,8 @@ export async function bundleRuntime(stage,{projectRoot,sourceDirectory}={}) {
|
|
|
35
35
|
if (originals.some(entry => !entry.isFile() || entry.isSymbolicLink())) {
|
|
36
36
|
throw new Error('Runtime source modules must be ordinary files.');
|
|
37
37
|
}
|
|
38
|
-
|
|
38
|
+
const selectedEntries = imageResponse ? runtimeEntryNames : runtimeEntryNames.filter(name => name !== 'og-loader');
|
|
39
|
+
for (const name of selectedEntries) {
|
|
39
40
|
if (!originals.some(entry => entry.name === `${name}.mjs`)) throw new Error(`Missing runtime entry ${name}.mjs`);
|
|
40
41
|
}
|
|
41
42
|
const sources=new Map();
|
|
@@ -52,7 +53,7 @@ export async function bundleRuntime(stage,{projectRoot,sourceDirectory}={}) {
|
|
|
52
53
|
if(projectRoot){
|
|
53
54
|
directory=await privateCacheDirectory(projectRoot,'runtime-bundle-v2');
|
|
54
55
|
if(directory){
|
|
55
|
-
const hash=createHash('sha256').update(esbuildVersion).update(JSON.stringify(
|
|
56
|
+
const hash=createHash('sha256').update(esbuildVersion).update(JSON.stringify(selectedEntries)).update(await readFile(new URL('./bundle-runtime.mjs',import.meta.url)));
|
|
56
57
|
for(const entry of [...originals].sort((a,b)=>a.name.localeCompare(b.name)))hash.update(JSON.stringify([entry.name,createHash('sha256').update(sources.get(path.join(input,entry.name))).digest('hex')]));
|
|
57
58
|
key=hash.digest('hex');cached=await readCacheRecord(directory,key);
|
|
58
59
|
}
|
|
@@ -61,10 +62,10 @@ export async function bundleRuntime(stage,{projectRoot,sourceDirectory}={}) {
|
|
|
61
62
|
// no files change if parsing or resolution fails. The virtual output is a
|
|
62
63
|
// sibling so all relative paths have the same depth as the final runtime.
|
|
63
64
|
const virtualOutput = path.join(resolvedStage, '.runtime-bundle');
|
|
64
|
-
const validCache=cached&&Array.isArray(cached.files)&&cached.files.length<=128&&cached.files.every(item=>Array.isArray(item)&&item.length===2&&/^[\w-]+\.mjs$/.test(item[0])&&typeof item[1]==='string')&&new Set(cached.files.map(([name])=>name)).size===cached.files.length&&
|
|
65
|
+
const validCache=cached&&Array.isArray(cached.files)&&cached.files.length<=128&&cached.files.every(item=>Array.isArray(item)&&item.length===2&&/^[\w-]+\.mjs$/.test(item[0])&&typeof item[1]==='string')&&new Set(cached.files.map(([name])=>name)).size===cached.files.length&&selectedEntries.every(name=>cached.files.some(([file])=>file===name+'.mjs'))&& (imageResponse || !cached.files.some(([file])=>file==='og-loader.mjs'));
|
|
65
66
|
const result = validCache?null:await build({
|
|
66
67
|
absWorkingDir: input,
|
|
67
|
-
entryPoints:
|
|
68
|
+
entryPoints: selectedEntries.map(name => path.join(input, `${name}.mjs`)),
|
|
68
69
|
outdir: virtualOutput,
|
|
69
70
|
outbase: input,
|
|
70
71
|
entryNames: '[name]',
|
|
@@ -78,7 +79,7 @@ export async function bundleRuntime(stage,{projectRoot,sourceDirectory}={}) {
|
|
|
78
79
|
packages: 'external',
|
|
79
80
|
// Edge's prepared VM is already a self-contained CJS artifact. Keeping it
|
|
80
81
|
// external preserves its delayed import and original execution boundary.
|
|
81
|
-
external: ['../compat/*', './profiles.json', './*.cjs'],
|
|
82
|
+
external: ['../compat/*', './profiles.json', './*.cjs', './og/*'],
|
|
82
83
|
minify: false,
|
|
83
84
|
sourcemap: false,
|
|
84
85
|
metafile: true,
|
|
@@ -94,7 +95,7 @@ export async function bundleRuntime(stage,{projectRoot,sourceDirectory}={}) {
|
|
|
94
95
|
}
|
|
95
96
|
emitted.set(path.basename(file.path), file.contents);
|
|
96
97
|
}
|
|
97
|
-
if (
|
|
98
|
+
if (selectedEntries.some(name => !emitted.has(`${name}.mjs`))) throw new Error('Runtime bundle omitted a required entry.');
|
|
98
99
|
if(directory&&!validCache)await writeCacheRecord(directory,key,{files:[...emitted].map(([name,bytes])=>[name,Buffer.from(bytes).toString()])});
|
|
99
100
|
// This is an unpublished, disposable stage. On any write failure buildProject
|
|
100
101
|
// discards the stage and preserves the previous published build unchanged.
|
|
@@ -105,6 +106,6 @@ export async function bundleRuntime(stage,{projectRoot,sourceDirectory}={}) {
|
|
|
105
106
|
}
|
|
106
107
|
// JSON, CJS, license files and the OG assets directory are never traversed or
|
|
107
108
|
// removed. The caller writes the normal runtime contract after prerender.
|
|
108
|
-
return { entryCount:
|
|
109
|
+
return { entryCount: selectedEntries.length, moduleCount: emitted.size,cached:!!validCache,
|
|
109
110
|
bytes: [...emitted.values()].reduce((sum, contents) => sum + contents.length, 0) };
|
|
110
111
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import {inspectCompilationStorage,pruneCompilationStorage,validateCompilationCache} from './compilation-storage.mjs';
|
|
3
|
+
|
|
4
|
+
export async function cacheCommand(args){
|
|
5
|
+
if(args.length===1&&['--help','-h'].includes(args[0])){
|
|
6
|
+
console.log('prnext cache [directory] [--json]\nprnext cache [directory] --prune [--dry-run] [--max-bytes N] [--json]\n\nInspect compiler caches without executing application config. The budget comes\nfrom the last build (default 256 MiB). --max-bytes overrides this purge only.\nRuntime data, SQLite, page/image caches and application files are excluded.\nStop development before purging. Active worker caches remain pinned.');return;
|
|
7
|
+
}
|
|
8
|
+
let directory,prune=false,dryRun=false,json=false,maxDiskSize;
|
|
9
|
+
const seen=new Set();
|
|
10
|
+
for(let i=0;i<args.length;i++){
|
|
11
|
+
const arg=args[i];
|
|
12
|
+
if(arg.startsWith('--')){if(seen.has(arg))throw Error(`Duplicate cache option: ${arg}`);seen.add(arg);}
|
|
13
|
+
if(arg==='--prune')prune=true;
|
|
14
|
+
else if(arg==='--dry-run')dryRun=true;
|
|
15
|
+
else if(arg==='--json')json=true;
|
|
16
|
+
else if(arg==='--max-bytes'){
|
|
17
|
+
const value=args[++i];if(!/^\d+$/.test(value||''))throw Error('--max-bytes requires a byte count from 0 to 4294967296.');
|
|
18
|
+
maxDiskSize=validateCompilationCache({maxDiskSize:Number(value)}).maxDiskSize;
|
|
19
|
+
}else if(arg.startsWith('-')||directory!==undefined)throw Error('cache accepts one directory, --prune, --dry-run, --max-bytes N and --json.');
|
|
20
|
+
else directory=arg;
|
|
21
|
+
}
|
|
22
|
+
if((dryRun||maxDiskSize!==undefined)&&!prune)throw Error('--dry-run and --max-bytes require --prune.');
|
|
23
|
+
const root=path.resolve(directory||'.');
|
|
24
|
+
const report=prune?await pruneCompilationStorage(root,{maxDiskSize,dryRun}):await inspectCompilationStorage(root);
|
|
25
|
+
if(json){console.log(JSON.stringify(report,null,2));return;}
|
|
26
|
+
const size=value=>(value/1024/1024).toFixed(2)+' MiB';
|
|
27
|
+
console.log(`Compilation caches: ${size(report.bytes)} / ${size(report.maxDiskSize)}`);
|
|
28
|
+
for(const category of report.categories)console.log(` ${category.name}: ${size(category.bytes)}, ${category.files} files${category.pinned?' (active worker)':''}`);
|
|
29
|
+
if(prune)console.log(report.deferred?report.reason:`${dryRun?'Would reclaim':'Reclaimed'} ${size(report.removedBytes)} in ${report.removedFiles} files; ${size(report.afterBytes)} retained.${report.overBudget?' Active or busy files remain above the budget.':''}`);
|
|
30
|
+
else if(!report.scanComplete)console.log('The scan limit was reached; totals are a lower bound.');
|
|
31
|
+
}
|
package/build/check.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
-
import { readFile } from 'node:fs/promises';
|
|
2
|
+
import { readFile,open } from 'node:fs/promises';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import { loadProjectConfig } from './config.mjs';
|
|
5
5
|
import { validateCompilerConfig } from './compiler.mjs';
|
|
@@ -10,11 +10,43 @@ import { snapshotEnvConfig } from '../runtime/env.mjs';
|
|
|
10
10
|
import { environmentDiagnostics } from '../native/diagnostics.mjs';
|
|
11
11
|
import { packageManifest } from '../native/resolve.mjs';
|
|
12
12
|
import { projectTsconfig } from './typescript.mjs';
|
|
13
|
+
import {productionDiagnostics} from './production-check.mjs';
|
|
14
|
+
import {readBuildDirectory} from '../runtime/build-directory.mjs';
|
|
15
|
+
import {validateServerConfig} from './server-config.mjs';
|
|
16
|
+
|
|
17
|
+
async function artifactDiagnostics(root,config){
|
|
18
|
+
let file;
|
|
19
|
+
const diagnostic=(code,severity,message,action)=>({id:code,code,path:'server',severity,message,action});
|
|
20
|
+
try{
|
|
21
|
+
const output=await readBuildDirectory(root);
|
|
22
|
+
file=await open(path.join(root,output,'manifest.json'),'r');
|
|
23
|
+
const limit=16*1024*1024,info=await file.stat();
|
|
24
|
+
if(!info.isFile()||info.size>limit)throw new Error('Uninspectable build manifest.');
|
|
25
|
+
const buffer=Buffer.alloc(Math.min(info.size+1,limit+1));let length=0;
|
|
26
|
+
while(length<buffer.length){const {bytesRead}=await file.read(buffer,length,buffer.length-length,length);if(!bytesRead)break;length+=bytesRead;}
|
|
27
|
+
const after=await file.stat();
|
|
28
|
+
if(length!==info.size||after.size!==info.size||after.mtimeMs!==info.mtimeMs||after.ctimeMs!==info.ctimeMs)throw new Error('Unstable build manifest.');
|
|
29
|
+
const manifest=JSON.parse(buffer.subarray(0,length).toString());
|
|
30
|
+
if(manifest.version!==1||!Array.isArray(manifest.routes))throw new Error('Unsupported build manifest.');
|
|
31
|
+
const diagnostics=[];
|
|
32
|
+
if(manifest.dev)diagnostics.push(diagnostic('production-artifact-development','error','The selected build is a development artifact.','Run prnext build and deploy the final production output.'));
|
|
33
|
+
if(output!==config.distDir)diagnostics.push({...diagnostic('production-artifact-directory','warning','The selected build directory differs from the current configuration.','Rebuild the application before deployment; prnext start uses the existing output selector.'),path:'distDir'});
|
|
34
|
+
const signature=server=>{const value=validateServerConfig(server);return JSON.stringify({publicOrigin:value.publicOrigin||null,trustedProxies:[...value.trustedProxies].sort()});};
|
|
35
|
+
if(signature(manifest.config?.server)!==signature(config.server))diagnostics.push(diagnostic('production-artifact-origin','warning','The selected build uses different public origin or proxy trust settings.','Rebuild and deploy the artifact with the intended production configuration; start does not reevaluate the source config.'));
|
|
36
|
+
const retention=value=>JSON.stringify({maxEntries:value===false?0:value?.maxEntries??4,authCookies:value?.authCookies===undefined?null:[...value.authCookies].sort()});
|
|
37
|
+
if(retention(manifest.config?.navigationCache)!==retention(config.navigationCache))diagnostics.push({...diagnostic('production-artifact-auth-cookies','warning','The selected build uses different page-retention or authentication cookie settings.','Rebuild before deployment, then test account changes on the resulting artifact.'),path:'navigationCache.authCookies'});
|
|
38
|
+
return diagnostics;
|
|
39
|
+
}catch(error){
|
|
40
|
+
if(error.code==='ENOENT')return [diagnostic('production-artifact-missing','info','No existing build was found at the selected output path.','Run prnext build after preflight, then qualify the resulting artifact.')];
|
|
41
|
+
return [diagnostic('production-artifact-unreadable','warning','The existing build metadata could not be inspected.','Run a fresh production build and verify its output contract before deployment.')];
|
|
42
|
+
}finally{await file?.close();}
|
|
43
|
+
}
|
|
13
44
|
|
|
14
45
|
/** Preflight an existing project without rewriting sources, dependencies or build output. */
|
|
15
|
-
export async function checkProject(directory, { dev = false, validateDependencies = true, validateEnvironment = validateDependencies } = {}, services = {}) {
|
|
46
|
+
export async function checkProject(directory, { dev = false, production = false, validateDependencies = true, validateEnvironment = validateDependencies } = {}, services = {}) {
|
|
47
|
+
if(dev&&production)throw new TypeError('Choose development or production preflight, not both.');
|
|
16
48
|
const root = path.resolve(directory);
|
|
17
|
-
const report = { root, ok: false, routes: [], versions: {}, errors: [], diagnostics: [], notes: [
|
|
49
|
+
const report = { root, mode:production?'production':dev?'development':'default', ok: false, routes: [], versions: {}, errors: [], diagnostics: [], notes: [
|
|
18
50
|
'Preflight validates configuration, route conventions and installed React versions. A successful production build and browser tests are still required.',
|
|
19
51
|
'Webpack plugin hooks are validated during compilation; preflight only validates plugin shape and translated configuration.',
|
|
20
52
|
'Rust handles HTTP, routing, static files, images and persistent caches. React and npm JavaScript execute on Node/V8.',
|
|
@@ -26,6 +58,7 @@ export async function checkProject(directory, { dev = false, validateDependencie
|
|
|
26
58
|
};
|
|
27
59
|
const restore = snapshotEnvConfig();
|
|
28
60
|
try {
|
|
61
|
+
if(production)process.env.NODE_ENV='production';
|
|
29
62
|
let config, project;
|
|
30
63
|
try { config = await loadProjectConfig(root, {dev}); }
|
|
31
64
|
catch (error) { diagnostic('config', error, 'Correct the project configuration and run check again.'); }
|
|
@@ -59,6 +92,11 @@ export async function checkProject(directory, { dev = false, validateDependencie
|
|
|
59
92
|
if (project) {
|
|
60
93
|
report.routes = project.routes.filter(route=>!route.internal).map(({pattern,kind,router})=>({pattern,kind,router:router||'pages'}));
|
|
61
94
|
}
|
|
95
|
+
if(production&&config){
|
|
96
|
+
const diagnostics=[...productionDiagnostics(config,project),...await artifactDiagnostics(root,config)];
|
|
97
|
+
report.diagnostics.push(...diagnostics);
|
|
98
|
+
report.errors.push(...diagnostics.filter(item=>item.severity==='error').map(item=>item.message));
|
|
99
|
+
}
|
|
62
100
|
const resolve = createRequire(path.join(root,'package.json'));
|
|
63
101
|
for (const name of ['next','react','react-dom','react-server-dom-webpack']) {
|
|
64
102
|
try { report.versions[name] = JSON.parse(await readFile(resolve.resolve(`${name}/package.json`),'utf8')).version; }
|
|
@@ -14,15 +14,15 @@ const MAX_FILES = 4096, MAX_DIRECTORIES = 8192, MAX_NAMES = 32768;
|
|
|
14
14
|
const MAX_FILE_BYTES = 16 * 1024 * 1024, MAX_BYTES = 48 * 1024 * 1024;
|
|
15
15
|
const RECORD_LIMIT = 64 * 1024 * 1024;
|
|
16
16
|
const SHA = /^[a-f0-9]{64}$/;
|
|
17
|
-
const frameworkDirectory=name=>['.git','.prnext-cache','.prnext','.prnext-incremental-stage'].includes(name)||/^\.prnext-(?:build|backup|config|postcss)-/.test(name);
|
|
17
|
+
const frameworkDirectory=name=>['.git','.prnext-cache','.prnext','.prnext-gc','.prnext-incremental-stage'].includes(name)||/^\.prnext-(?:build|backup|config|postcss)-/.test(name);
|
|
18
18
|
const frameworkPointer=name=>/^\.prnext-output(?:\.json|-.+\.tmp)$/.test(name);
|
|
19
19
|
const ignored = entry => entry.isDirectory() && (entry.name==='node_modules'||frameworkDirectory(entry.name)) || frameworkPointer(entry.name);
|
|
20
20
|
const safePath = file => typeof file === 'string' && file.length < 32768 && path.isAbsolute(file) && !file.includes('\0');
|
|
21
21
|
const inside = (root, file) => { const relative = path.relative(root, file); return relative === '' || !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep); };
|
|
22
|
-
export const withCompilationGraphCacheReporting = run => reports.run({ kinds: new Map(), identities:new Map(), listings:new Map(),listingNames:0 }, run);
|
|
22
|
+
export const withCompilationGraphCacheReporting = run => reports.run({ kinds: new Map(), identities:new Map(), listings:new Map(),listingNames:0,discovery:{metadataReads:0,metadataHits:0,parentVisits:0} }, run);
|
|
23
23
|
export function compilationGraphCacheReport() {
|
|
24
24
|
const kinds = [...(reports.getStore()?.kinds || [])].map(([kind, value]) => ({ kind, ...value, reasons: { ...value.reasons } }));
|
|
25
|
-
return { version: 1, scope: 'eligible-production-compilation-graphs', kinds,
|
|
25
|
+
return { version: 1, scope: 'eligible-production-compilation-graphs', kinds,discovery:{...(reports.getStore()?.discovery||{metadataReads:0,metadataHits:0,parentVisits:0})},
|
|
26
26
|
hits: kinds.reduce((sum, item) => sum + item.hits, 0), misses: kinds.reduce((sum, item) => sum + item.misses, 0),
|
|
27
27
|
bypasses: kinds.reduce((sum, item) => sum + item.bypasses, 0), reusedBytes: kinds.reduce((sum, item) => sum + item.reusedBytes, 0) };
|
|
28
28
|
}
|
|
@@ -83,20 +83,34 @@ async function directoryDigest(directory, budget, started = Infinity, lookup = f
|
|
|
83
83
|
async function snapshot(root, dependencies, { started = Infinity, imports = [], rejectTsconfig = false, styleTargets=false, baseline, excluded = [] } = {}) {
|
|
84
84
|
if (!Array.isArray(dependencies) || dependencies.length > MAX_FILES || dependencies.some(file => !safePath(file))) throw Error('dependency-limit');
|
|
85
85
|
const files = new Set(dependencies), directories = new Set(), trees = new Set([root]), fullDirectories = new Set();
|
|
86
|
+
// Discovery metadata is shared only within this snapshot. The final source
|
|
87
|
+
// digests and directory listings below always perform their own validation.
|
|
88
|
+
const metadataCache=new Map(),expandedParents=new Set(),packageRoots=new Map(),stats=reports.getStore()?.discovery;
|
|
89
|
+
const metadata=async file=>{
|
|
90
|
+
if(metadataCache.has(file)){if(stats)stats.metadataHits++;return metadataCache.get(file);}
|
|
91
|
+
if(metadataCache.size>=MAX_DIRECTORIES*8)throw Error('resolution-limit');
|
|
92
|
+
if(stats)stats.metadataReads++;
|
|
93
|
+
const pending=lstat(file).catch(error=>{if(error.code==='ENOENT')return;throw error;});
|
|
94
|
+
metadataCache.set(file,pending);return pending;
|
|
95
|
+
};
|
|
86
96
|
if(dependencies.some(file=>path.dirname(file).split(path.sep).some(frameworkDirectory)||frameworkPointer(path.basename(file))||excluded.some(directory=>inside(directory,file))))throw Error('generated-input');
|
|
87
97
|
for(const name of ['package.json','package-lock.json','npm-shrinkwrap.json','pnpm-lock.yaml','yarn.lock','bun.lock','bun.lockb','.env','.env.local','.env.production','.env.production.local','.env.development','.env.development.local','.env.test','.env.test.local']){
|
|
88
|
-
const file=path.join(root,name)
|
|
98
|
+
const file=path.join(root,name),info=await metadata(file);if(info){if(!info.isFile()||info.isSymbolicLink())throw Error('unsafe-config');files.add(file);}
|
|
89
99
|
}
|
|
90
100
|
const addParents = async file => {
|
|
91
101
|
let directory = path.dirname(file);
|
|
92
102
|
while (true) {
|
|
103
|
+
if(expandedParents.has(directory))break;
|
|
104
|
+
expandedParents.add(directory);if(stats)stats.parentVisits++;
|
|
93
105
|
directories.add(directory);
|
|
94
106
|
// Node's package search includes missing nearer node_modules directories.
|
|
95
107
|
directories.add(path.join(directory, 'node_modules'));
|
|
108
|
+
if(directories.size>MAX_DIRECTORIES)throw Error('resolution-limit');
|
|
96
109
|
for (const name of ['package.json', 'tsconfig.json', 'jsconfig.json', ...(styleTargets?['.browserslistrc','browserslist']:[])]) {
|
|
97
110
|
const candidate = path.join(directory, name);
|
|
98
111
|
try {
|
|
99
|
-
const info = await
|
|
112
|
+
const info = await metadata(candidate);
|
|
113
|
+
if(!info)continue;
|
|
100
114
|
if(name==='browserslist'&&info.isDirectory())continue;
|
|
101
115
|
if (rejectTsconfig && /^(?:ts|js)config\.json$/.test(name)) throw Error('untracked-tsconfig');
|
|
102
116
|
if (!info.isFile() || info.isSymbolicLink() || info.size>MAX_FILE_BYTES) throw Error('unsafe-config');
|
|
@@ -111,16 +125,22 @@ async function snapshot(root, dependencies, { started = Infinity, imports = [],
|
|
|
111
125
|
const parent = path.dirname(directory); if (parent === directory) break; directory = parent;
|
|
112
126
|
}
|
|
113
127
|
};
|
|
128
|
+
const packageRoot=async file=>{
|
|
129
|
+
let directory=path.dirname(file),found,visited=[];
|
|
130
|
+
while(true){
|
|
131
|
+
if(packageRoots.has(directory)){found=packageRoots.get(directory);break;}
|
|
132
|
+
visited.push(directory);
|
|
133
|
+
if((await metadata(path.join(directory,'package.json')))?.isFile()){found=directory;break;}
|
|
134
|
+
const parent=path.dirname(directory);if(parent===directory)break;directory=parent;
|
|
135
|
+
}
|
|
136
|
+
for(const directory of visited)packageRoots.set(directory,found);
|
|
137
|
+
return found;
|
|
138
|
+
};
|
|
114
139
|
for (const file of dependencies) {
|
|
115
140
|
await addParents(file);
|
|
116
141
|
// Package export/browser maps may choose an absent file anywhere inside the
|
|
117
142
|
// package. Directory names, including absence, participate in invalidation.
|
|
118
|
-
|
|
119
|
-
while (true) {
|
|
120
|
-
try { if ((await lstat(path.join(directory, 'package.json'))).isFile()) { trees.add(directory); break; } }
|
|
121
|
-
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
122
|
-
const parent = path.dirname(directory); if (parent === directory) break; directory = parent;
|
|
123
|
-
}
|
|
143
|
+
const tree=await packageRoot(file);if(tree)trees.add(tree);
|
|
124
144
|
}
|
|
125
145
|
for (const [importer, specifier] of imports) {
|
|
126
146
|
if (typeof specifier !== 'string' || /^(?:node:|[./\\]|[A-Za-z]:)/.test(specifier)) continue;
|
|
@@ -129,7 +149,7 @@ async function snapshot(root, dependencies, { started = Infinity, imports = [],
|
|
|
129
149
|
let directory = path.dirname(importer);
|
|
130
150
|
while (true) {
|
|
131
151
|
const candidate = path.join(directory, 'node_modules', name);
|
|
132
|
-
try { const info = await
|
|
152
|
+
try { const info = await metadata(candidate); if(info){if (info.isSymbolicLink()) throw Error('linked-package'); if (info.isDirectory()) trees.add(candidate);} }
|
|
133
153
|
catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
134
154
|
const parent = path.dirname(directory); if (parent === directory) break; directory = parent;
|
|
135
155
|
}
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Only these framework-owned, regenerable caches participate in the common
|
|
2
|
+
// budget. Runtime data, page/image indexes, invalidations and app files do not.
|
|
3
|
+
import {AsyncLocalStorage} from 'node:async_hooks';
|
|
4
|
+
import {lstat,open,opendir,realpath,rename,unlink} from 'node:fs/promises';
|
|
5
|
+
import {constants} from 'node:fs';
|
|
6
|
+
import {hostname} from 'node:os';
|
|
7
|
+
import {randomUUID} from 'node:crypto';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import {withBuildLock} from './build-lock.mjs';
|
|
10
|
+
import {captureStorageDirectory,verifyStorageDirectories,sameStoragePath} from '../runtime/storage-path.mjs';
|
|
11
|
+
|
|
12
|
+
const MiB=1024*1024,DEFAULT=256*MiB,MAX_SCAN=32768;
|
|
13
|
+
const storage=new AsyncLocalStorage();
|
|
14
|
+
const namespaces={
|
|
15
|
+
'transforms-v1':/^[a-f0-9]{64}\.json$/,
|
|
16
|
+
'transforms-dev-v1':/^[a-f0-9]{64}\.json$/,
|
|
17
|
+
'runtime-bundle-v2':/^[a-f0-9]{64}\.json$/,
|
|
18
|
+
'loaders-v2':/^[a-f0-9]{64}\.json$/,
|
|
19
|
+
'compilation-graphs-v1':/^[a-f0-9]{64}\.json$/,
|
|
20
|
+
'compilation-graphs-v2':/^[a-f0-9]{64}\.(?:graph|blob)$/,
|
|
21
|
+
'fonts-v1':/^[a-f0-9]{64}\.bin$/,
|
|
22
|
+
'precompress-v1':/^[a-f0-9]{64}\.(?:gz|br)$/,
|
|
23
|
+
'dev-compiler-v8':null,
|
|
24
|
+
'node-compile-v1':null,
|
|
25
|
+
};
|
|
26
|
+
export function validateCompilationCache(value={}){
|
|
27
|
+
if(!value||typeof value!=='object'||![Object.prototype,null].includes(Object.getPrototypeOf(value))||Reflect.ownKeys(value).some(key=>key!=='maxDiskSize'))throw new TypeError('compilationCache accepts only maxDiskSize, in bytes.');
|
|
28
|
+
const maxDiskSize=value.maxDiskSize===undefined?DEFAULT:value.maxDiskSize;
|
|
29
|
+
if(!Number.isSafeInteger(maxDiskSize)||maxDiskSize<0||maxDiskSize>4*1024*MiB)throw new TypeError('compilationCache.maxDiskSize must be an integer from 0 to 4294967296 bytes.');
|
|
30
|
+
return {maxDiskSize};
|
|
31
|
+
}
|
|
32
|
+
export function configureCompilationStorage(policy){const context=storage.getStore();if(context)context.policy=validateCompilationCache(policy);}
|
|
33
|
+
async function realDirectory(directory){const info=await lstat(directory).catch(()=>null);return info?.isDirectory()&&!info.isSymbolicLink();}
|
|
34
|
+
const sameFile=(left,right)=>left.isFile()&&!left.isSymbolicLink()&&left.ino===right.ino&&left.dev===right.dev&&left.size===right.size&&left.mtimeMs===right.mtimeMs;
|
|
35
|
+
async function unchangedFile(file,snapshot,guards){
|
|
36
|
+
if(!await verifyStorageDirectories(guards))return false;
|
|
37
|
+
const current=await lstat(file).catch(()=>null);
|
|
38
|
+
return !!current&&sameFile(current,snapshot)&&sameStoragePath(await realpath(file),file)&&await verifyStorageDirectories(guards);
|
|
39
|
+
}
|
|
40
|
+
async function boundedJson(file,guards){
|
|
41
|
+
let descriptor;
|
|
42
|
+
try{
|
|
43
|
+
if(guards&&!await verifyStorageDirectories(guards))return null;
|
|
44
|
+
const info=await lstat(file);if(!info.isFile()||info.isSymbolicLink()||info.size>4096)return null;
|
|
45
|
+
descriptor=await open(file,constants.O_RDONLY|(constants.O_NOFOLLOW||0));
|
|
46
|
+
if(!sameFile(await descriptor.stat(),info))return null;
|
|
47
|
+
const contents=await descriptor.readFile('utf8');
|
|
48
|
+
if(guards&&!await unchangedFile(file,info,guards))return null;
|
|
49
|
+
return JSON.parse(contents);
|
|
50
|
+
}catch{return null;}finally{await descriptor?.close().catch(()=>{});}
|
|
51
|
+
}
|
|
52
|
+
function alive(pid){try{process.kill(pid,0);return true;}catch(error){return error.code!=='ESRCH';}}
|
|
53
|
+
async function pinned(directory,{clean=false,guards}={}){
|
|
54
|
+
let scanned=0,active=false;
|
|
55
|
+
if(!await verifyStorageDirectories(guards))return {active:true,changed:true};
|
|
56
|
+
try{
|
|
57
|
+
for await(const entry of await opendir(directory)){
|
|
58
|
+
if(++scanned>8192)return {active:true,changed:false};
|
|
59
|
+
const name=entry.name;
|
|
60
|
+
if(!/^\.lease-\d+-[a-f0-9-]{36}\.json$/.test(name))continue;
|
|
61
|
+
const file=path.join(directory,name),info=await lstat(file).catch(()=>null),owner=await boundedJson(file,guards);
|
|
62
|
+
if(!owner||owner.host!==hostname()||!Number.isSafeInteger(owner.pid)||owner.pid<1||String(owner.pid)!==name.split('-')[1]||alive(owner.pid)){active=true;continue;}
|
|
63
|
+
if(clean&&info&&await unchangedFile(file,info,guards))await unlink(file).catch(()=>{});
|
|
64
|
+
}
|
|
65
|
+
}catch{return {active:true,changed:true};}
|
|
66
|
+
return {active,changed:!await verifyStorageDirectories(guards)};
|
|
67
|
+
}
|
|
68
|
+
async function storedPolicy(root){return validateCompilationCache(await boundedJson(path.join(root,'.prnext-cache','compilation-policy.json'))||{});}
|
|
69
|
+
async function savePolicy(root,policy){
|
|
70
|
+
const directory=path.join(root,'.prnext-cache'),guards=[await captureStorageDirectory(root),await captureStorageDirectory(directory)];
|
|
71
|
+
if(!await verifyStorageDirectories(guards))return;
|
|
72
|
+
const temporary=path.join(directory,`.policy-${randomUUID()}.tmp`);
|
|
73
|
+
let temporaryInfo;
|
|
74
|
+
try{
|
|
75
|
+
if(!await verifyStorageDirectories(guards))return;
|
|
76
|
+
const file=await open(temporary,'wx',0o600);try{await file.writeFile(JSON.stringify(policy));temporaryInfo=await file.stat();}finally{await file.close();}
|
|
77
|
+
if(await unchangedFile(temporary,temporaryInfo,guards))await rename(temporary,path.join(directory,'compilation-policy.json'));
|
|
78
|
+
}catch{/* Optional cache metadata cannot fail compilation. */}
|
|
79
|
+
finally{if(temporaryInfo&&await unchangedFile(temporary,temporaryInfo,guards).catch(()=>false))await unlink(temporary).catch(()=>{});}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Stats never execute application config. The budget is from the last build. */
|
|
83
|
+
export async function compilationStorageStats(root,{maxDiskSize}={}){
|
|
84
|
+
root=await realpath(root);const base=path.join(root,'.prnext-cache');
|
|
85
|
+
let policy;
|
|
86
|
+
if(maxDiskSize!==undefined)policy=validateCompilationCache({maxDiskSize});
|
|
87
|
+
else try{policy=await storedPolicy(root);}catch{policy=validateCompilationCache();}
|
|
88
|
+
const report={version:1,root,maxDiskSize:policy.maxDiskSize,bytes:0,files:0,scanComplete:true,categories:[],excluded:['runtime data and SQLite','page and image caches','unknown files and directories']};
|
|
89
|
+
if(!await realDirectory(base))return report;
|
|
90
|
+
const baseGuards=[await captureStorageDirectory(root),await captureStorageDirectory(base)];
|
|
91
|
+
if(!await verifyStorageDirectories(baseGuards)){report.scanComplete=false;return report;}
|
|
92
|
+
let scanned=0;
|
|
93
|
+
for(const [name,pattern] of Object.entries(namespaces)){
|
|
94
|
+
const directory=path.join(base,name);if(!await realDirectory(directory))continue;
|
|
95
|
+
const guards=[...baseGuards,await captureStorageDirectory(directory)],treeGuards=[...guards];
|
|
96
|
+
const lease=await pinned(directory,{guards});
|
|
97
|
+
if(lease.changed){report.scanComplete=false;break;}
|
|
98
|
+
const category={name,bytes:0,files:0,pinned:lease.active,entries:[],guards};
|
|
99
|
+
async function collect(folder,parentGuards,depth=0){
|
|
100
|
+
if(depth>3){report.scanComplete=false;return;}
|
|
101
|
+
if(!await verifyStorageDirectories(parentGuards)){report.scanComplete=false;return;}
|
|
102
|
+
try{
|
|
103
|
+
for await(const entry of await opendir(folder)){
|
|
104
|
+
if(++scanned>MAX_SCAN){report.scanComplete=false;return;}
|
|
105
|
+
const filename=path.join(folder,entry.name),info=await lstat(filename).catch(()=>null);
|
|
106
|
+
if(!info||info.isSymbolicLink())continue;
|
|
107
|
+
if(!pattern&&info.isDirectory()&&!entry.name.startsWith('.')){
|
|
108
|
+
const snapshot=await captureStorageDirectory(filename);treeGuards.push(snapshot);
|
|
109
|
+
await collect(filename,[...parentGuards,snapshot],depth+1);
|
|
110
|
+
}else if(info.isFile()){
|
|
111
|
+
const temporary=!!pattern&&/^[a-f0-9]{64}\.(?:json|graph|blob|bin|gz|br)\.[a-f0-9-]{36}\.tmp$/.test(entry.name);
|
|
112
|
+
if(!(pattern?pattern.test(entry.name)||temporary:!entry.name.startsWith('.')))continue;
|
|
113
|
+
category.entries.push({filename,size:info.size,time:info.mtimeMs,ino:info.ino,dev:info.dev,temporary,pinned:temporary&&Date.now()-info.mtimeMs<3600000,guards:parentGuards});category.bytes+=info.size;category.files++;
|
|
114
|
+
}
|
|
115
|
+
if(!report.scanComplete)return;
|
|
116
|
+
}
|
|
117
|
+
}catch{report.scanComplete=false;}
|
|
118
|
+
if(!await verifyStorageDirectories(parentGuards))report.scanComplete=false;
|
|
119
|
+
}
|
|
120
|
+
await collect(directory,guards);
|
|
121
|
+
if(!await verifyStorageDirectories(treeGuards))report.scanComplete=false;
|
|
122
|
+
report.categories.push(category);report.bytes+=category.bytes;report.files+=category.files;
|
|
123
|
+
if(!report.scanComplete)break;
|
|
124
|
+
}
|
|
125
|
+
return report;
|
|
126
|
+
}
|
|
127
|
+
function publicReport(report){return {...report,categories:report.categories.map(({entries,guards,...category})=>category)};}
|
|
128
|
+
export async function inspectCompilationStorage(root,options){return publicReport(await compilationStorageStats(root,options));}
|
|
129
|
+
|
|
130
|
+
// Called only under the existing interprocess publisher lock. Long-lived dev
|
|
131
|
+
// compilers retain their cache until shutdown; managed V8 workers pin theirs.
|
|
132
|
+
export async function pruneCompilationStorage(root,{maxDiskSize,dryRun=false,locked=false}={}){
|
|
133
|
+
if(!locked)return withBuildLock(root,canonical=>pruneCompilationStorage(canonical,{maxDiskSize,dryRun,locked:true}));
|
|
134
|
+
const report=await compilationStorageStats(root,{maxDiskSize});
|
|
135
|
+
let dev=false;try{await lstat(path.join(report.root,'.prnext-dev.lock'));dev=true;}catch(error){if(error.code!=='ENOENT')dev=true;}
|
|
136
|
+
const result={...publicReport(report),dryRun,beforeBytes:report.bytes,afterBytes:report.bytes,removedBytes:0,removedFiles:0,deferred:dev||!report.scanComplete,reason:dev?'Development is running; stop it to reclaim compilation caches.':!report.scanComplete?'The scan limit was reached; no files were removed.':undefined};
|
|
137
|
+
if(result.deferred)return result;
|
|
138
|
+
const units=[];
|
|
139
|
+
for(const category of report.categories){
|
|
140
|
+
if(category.pinned)continue;
|
|
141
|
+
const lease=await pinned(path.join(report.root,'.prnext-cache',category.name),{clean:!dryRun,guards:category.guards});
|
|
142
|
+
if(lease.changed){result.scanComplete=false;result.deferred=true;result.reason='A compiler cache directory changed; no further files were removed.';return result;}
|
|
143
|
+
if(lease.active){result.categories.find(value=>value.name===category.name).pinned=true;continue;}
|
|
144
|
+
// Graph records share content-addressed blobs. Evict this bounded namespace
|
|
145
|
+
// together so a global trim cannot strand metadata or remove shared bytes.
|
|
146
|
+
if(category.name==='compilation-graphs-v2'&&category.entries.length){
|
|
147
|
+
const graphs=category.entries.filter(entry=>!entry.temporary);if(graphs.length)units.push(graphs);
|
|
148
|
+
for(const entry of category.entries)if(entry.temporary&&!entry.pinned)units.push([entry]);
|
|
149
|
+
}
|
|
150
|
+
else for(const entry of category.entries)if(!entry.pinned)units.push([entry]);
|
|
151
|
+
}
|
|
152
|
+
units.sort((a,b)=>Math.max(...a.map(e=>e.time))-Math.max(...b.map(e=>e.time)));
|
|
153
|
+
for(const entries of units){
|
|
154
|
+
if(result.afterBytes<=report.maxDiskSize&&!entries.some(entry=>entry.temporary))continue;
|
|
155
|
+
for(const entry of entries){
|
|
156
|
+
try{
|
|
157
|
+
// Do not unlink an entry replaced after this snapshot (or a symlink).
|
|
158
|
+
if(!await verifyStorageDirectories(entry.guards)){result.scanComplete=false;result.deferred=true;result.reason='A compiler cache directory changed; no further files were removed.';return result;}
|
|
159
|
+
const current=await lstat(entry.filename);
|
|
160
|
+
if(!current.isFile()||current.isSymbolicLink()||current.ino!==entry.ino||current.dev!==entry.dev||current.size!==entry.size||current.mtimeMs!==entry.time)continue;
|
|
161
|
+
if(!sameStoragePath(await realpath(entry.filename),entry.filename)||!await verifyStorageDirectories(entry.guards)){result.scanComplete=false;result.deferred=true;result.reason='A compiler cache directory changed; no further files were removed.';return result;}
|
|
162
|
+
if(!dryRun)await unlink(entry.filename);
|
|
163
|
+
result.afterBytes-=entry.size;result.removedBytes+=entry.size;result.removedFiles++;
|
|
164
|
+
}catch{/* Busy/changed files remain visible and retryable next time. */}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
result.overBudget=result.afterBytes>report.maxDiskSize;
|
|
168
|
+
return result;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export async function withCompilationStorage(root,run){
|
|
172
|
+
if(process.env.PRNEXT_BUILD_CACHE==='0')return run();
|
|
173
|
+
let policy;try{policy=await storedPolicy(root);}catch{policy=validateCompilationCache();}
|
|
174
|
+
const context={policy};
|
|
175
|
+
return storage.run(context,async()=>{
|
|
176
|
+
let value;
|
|
177
|
+
try{value=await run();return value;}
|
|
178
|
+
finally{
|
|
179
|
+
await savePolicy(root,context.policy);
|
|
180
|
+
try{
|
|
181
|
+
// A live dev session pins compiler state. Avoid a full disk scan after
|
|
182
|
+
// every keystroke; shutdown applies the stored policy once it drains.
|
|
183
|
+
const dev=await lstat(path.join(root,'.prnext-dev.lock')).catch(()=>null);
|
|
184
|
+
const report=dev?{version:1,...context.policy,deferred:true,reason:'Development is running; maintenance runs after shutdown.'}:await pruneCompilationStorage(root,{...context.policy,locked:true});
|
|
185
|
+
if(value&&typeof value==='object')value.compilationStorage=report;
|
|
186
|
+
}
|
|
187
|
+
catch{/* Retention failure must not hide a build error. */}
|
|
188
|
+
}
|
|
189
|
+
});
|
|
190
|
+
}
|