@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.
Files changed (162) hide show
  1. package/README.md +2 -0
  2. package/build/actions.mjs +26 -14
  3. package/build/app.mjs +90 -101
  4. package/build/build-lock.mjs +2 -71
  5. package/build/build-retention.mjs +119 -0
  6. package/build/bundle-runtime.mjs +9 -8
  7. package/build/cache-command.mjs +31 -0
  8. package/build/check.mjs +41 -3
  9. package/build/compilation-cache.mjs +32 -12
  10. package/build/compilation-storage.mjs +190 -0
  11. package/build/compiler.mjs +8 -7
  12. package/build/config.mjs +25 -2
  13. package/build/create.mjs +1 -1
  14. package/build/css-modules.mjs +4 -0
  15. package/build/dev-action-graph.mjs +21 -0
  16. package/build/dev-css-assets.mjs +173 -0
  17. package/build/dev-css-update.mjs +14 -12
  18. package/build/dev-lazy-build.mjs +77 -0
  19. package/build/dynamic.mjs +13 -6
  20. package/build/font-cache.mjs +9 -6
  21. package/build/font-files.mjs +1 -1
  22. package/build/incremental.mjs +112 -18
  23. package/build/index.mjs +61 -15
  24. package/build/lazy-dynamic-imports.mjs +137 -0
  25. package/build/migrate.mjs +4 -4
  26. package/build/migration-audit.mjs +19 -31
  27. package/build/migration-imports.mjs +245 -0
  28. package/build/migration-scenarios-runner.mjs +73 -0
  29. package/build/migration-scenarios.mjs +72 -0
  30. package/build/native-discovery.mjs +65 -5
  31. package/build/native-packages.mjs +2 -0
  32. package/build/native-ppr.mjs +32 -0
  33. package/build/npm-packages.mjs +1 -0
  34. package/build/og.mjs +121 -1
  35. package/build/parallel-plan.mjs +55 -0
  36. package/build/persistent-cache.mjs +29 -24
  37. package/build/production-builder.mjs +1 -1
  38. package/build/production-check.mjs +34 -0
  39. package/build/react-compiler-report.mjs +31 -8
  40. package/build/react-compiler.mjs +101 -10
  41. package/build/server-config.mjs +35 -0
  42. package/build/source-analysis.mjs +54 -4
  43. package/build/standalone.mjs +21 -3
  44. package/build/static-adapter.mjs +7 -3
  45. package/build/transform.mjs +38 -6
  46. package/build/typescript-dev.mjs +3 -1
  47. package/build/webpack-graph.mjs +13 -3
  48. package/cli.mjs +24 -6
  49. package/compat/app-dynamic.cjs +12 -1
  50. package/compat/app-layout-context.cjs +7 -3
  51. package/compat/cache-handlers.cjs +3 -1
  52. package/compat/flight-delta.cjs +50 -13
  53. package/compat/index.d.ts +24 -1
  54. package/compat/link.cjs +2 -2
  55. package/compat/navigation-authorization.cjs +8 -1
  56. package/docs/ai/compatibility.md +187 -7
  57. package/docs/ai/deployment.md +141 -0
  58. package/docs/ai/quickstart.md +5 -0
  59. package/docs/ai/routing.md +11 -0
  60. package/docs/ai/troubleshooting.md +79 -0
  61. package/package.json +26 -11
  62. package/release/fontkit.mjs +31 -0
  63. package/release/verify.mjs +3 -0
  64. package/runtime/api.mjs +5 -2
  65. package/runtime/app-client.mjs +254 -53
  66. package/runtime/app-partial.mjs +2 -10
  67. package/runtime/app-prefetch.mjs +2 -2
  68. package/runtime/app-render.mjs +11 -7
  69. package/runtime/app-stream-channel.mjs +3 -2
  70. package/runtime/app-styles.mjs +22 -4
  71. package/runtime/atomic-file.mjs +27 -0
  72. package/runtime/cache-components-rsc.mjs +3 -1
  73. package/runtime/compile-cache-retention.mjs +53 -0
  74. package/runtime/compile-cache.mjs +28 -1
  75. package/runtime/dev-client.mjs +13 -5
  76. package/runtime/dev-file-changes.mjs +123 -8
  77. package/runtime/dev-gateway.mjs +3 -2
  78. package/runtime/dev-lazy-imports.mjs +121 -0
  79. package/runtime/dev-lazy-staging.mjs +14 -0
  80. package/runtime/dev-route-selection.mjs +64 -0
  81. package/runtime/dev-runtime-pool.mjs +3 -2
  82. package/runtime/dev-runtime-spare.mjs +1 -1
  83. package/runtime/dev-session-retention.mjs +154 -0
  84. package/runtime/dev-style-retention.mjs +15 -0
  85. package/runtime/development.mjs +86 -26
  86. package/runtime/env.mjs +9 -0
  87. package/runtime/flight-delta.mjs +59 -9
  88. package/runtime/fs.mjs +15 -8
  89. package/runtime/lazy-import-client.mjs +38 -0
  90. package/runtime/native-ppr.mjs +35 -0
  91. package/runtime/og/NOTICE.txt +1 -1
  92. package/runtime/og/index.node.mjs +76 -31216
  93. package/runtime/og/upstream.json +115 -0
  94. package/runtime/persistent/client.mjs +4 -2
  95. package/runtime/persistent/daemon.mjs +3 -11
  96. package/runtime/persistent/health.mjs +58 -0
  97. package/runtime/project-lock.mjs +71 -0
  98. package/runtime/request-transport.mjs +112 -32
  99. package/runtime/storage-path.mjs +36 -0
  100. package/runtime/trace.mjs +44 -4
  101. package/runtime/worker.mjs +9 -4
  102. package/vendor/fontkit/NOTICE.txt +5 -0
  103. package/vendor/fontkit/SOURCE-NOTICES.txt +209 -0
  104. package/vendor/fontkit/index.mjs +3 -0
  105. package/vendor/fontkit/licenses/@swc__helpers/LICENSE +201 -0
  106. package/vendor/fontkit/licenses/base64-js/LICENSE +21 -0
  107. package/vendor/fontkit/licenses/brotli/readme.md +61 -0
  108. package/vendor/fontkit/licenses/clone/LICENSE +18 -0
  109. package/vendor/fontkit/licenses/dfa/README.md +83 -0
  110. package/vendor/fontkit/licenses/fast-deep-equal/LICENSE +21 -0
  111. package/vendor/fontkit/licenses/fontkit/README.md +266 -0
  112. package/vendor/fontkit/licenses/restructure/LICENSE +21 -0
  113. package/vendor/fontkit/licenses/tiny-inflate/LICENSE +21 -0
  114. package/vendor/fontkit/licenses/tslib/LICENSE.txt +12 -0
  115. package/vendor/fontkit/licenses/unicode-properties/LICENSE +8 -0
  116. package/vendor/fontkit/licenses/unicode-trie/LICENSE +8 -0
  117. package/vendor/fontkit/upstream.json +215 -0
  118. package/vendor/nft/LICENSE +7 -0
  119. package/vendor/nft/README.md +16 -0
  120. package/vendor/nft/out/analyze.d.ts +8 -0
  121. package/vendor/nft/out/analyze.js +1216 -0
  122. package/vendor/nft/out/analyze.js.map +1 -0
  123. package/vendor/nft/out/cli.d.ts +2 -0
  124. package/vendor/nft/out/cli.js +94 -0
  125. package/vendor/nft/out/cli.js.map +1 -0
  126. package/vendor/nft/out/fs.d.ts +22 -0
  127. package/vendor/nft/out/fs.js +106 -0
  128. package/vendor/nft/out/fs.js.map +1 -0
  129. package/vendor/nft/out/index.d.ts +4 -0
  130. package/vendor/nft/out/index.js +25 -0
  131. package/vendor/nft/out/index.js.map +1 -0
  132. package/vendor/nft/out/node-file-trace.d.ts +41 -0
  133. package/vendor/nft/out/node-file-trace.js +380 -0
  134. package/vendor/nft/out/node-file-trace.js.map +1 -0
  135. package/vendor/nft/out/resolve-dependency.d.ts +6 -0
  136. package/vendor/nft/out/resolve-dependency.js +427 -0
  137. package/vendor/nft/out/resolve-dependency.js.map +1 -0
  138. package/vendor/nft/out/types.d.ts +66 -0
  139. package/vendor/nft/out/types.js +2 -0
  140. package/vendor/nft/out/types.js.map +1 -0
  141. package/vendor/nft/out/utils/ast-helpers.js +49 -0
  142. package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
  143. package/vendor/nft/out/utils/binary-locators.js +88 -0
  144. package/vendor/nft/out/utils/binary-locators.js.map +1 -0
  145. package/vendor/nft/out/utils/get-package-base.js +30 -0
  146. package/vendor/nft/out/utils/get-package-base.js.map +1 -0
  147. package/vendor/nft/out/utils/interop-require.js +24 -0
  148. package/vendor/nft/out/utils/interop-require.js.map +1 -0
  149. package/vendor/nft/out/utils/node-version.js +10 -0
  150. package/vendor/nft/out/utils/node-version.js.map +1 -0
  151. package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
  152. package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
  153. package/vendor/nft/out/utils/special-cases.js +347 -0
  154. package/vendor/nft/out/utils/special-cases.js.map +1 -0
  155. package/vendor/nft/out/utils/static-eval.js +579 -0
  156. package/vendor/nft/out/utils/static-eval.js.map +1 -0
  157. package/vendor/nft/out/utils/types.js +2 -0
  158. package/vendor/nft/out/utils/types.js.map +1 -0
  159. package/vendor/nft/out/utils/wrappers.js +659 -0
  160. package/vendor/nft/out/utils/wrappers.js.map +1 -0
  161. package/vendor/nft/package.json +4 -0
  162. package/vendor/nft/upstream.json +51 -0
@@ -0,0 +1,121 @@
1
+ import path from 'node:path';
2
+ import {constants} from 'node:fs';
3
+ import {readdir,readFile,writeFile,stat,lstat,open,mkdtemp,link,rm} from 'node:fs/promises';
4
+
5
+ export const MAX_LAZY_ASSET_BYTES=128*1024*1024;
6
+ export const MAX_LAZY_ASSET_FILES=16384;
7
+ const token=/^\/_prnext\/lazy\/([a-f0-9]{24})$/;
8
+ export function lazyImportIdentifier(raw){return token.exec(new URL(raw,'http://local').pathname)?.[1];}
9
+
10
+ function receiptFiles(receipt,cacheId){
11
+ if(!receipt||receipt.version!==1||typeof cacheId!=='string'||!cacheId||cacheId.length>256||receipt.baseCacheId!==cacheId||!Array.isArray(receipt.files))throw Error('Invalid or obsolete deferred asset receipt');
12
+ if(receipt.files.length>MAX_LAZY_ASSET_FILES)throw Error('Deferred import exceeded the asset count budget');
13
+ const names=new Set(),files=[];let total=0;
14
+ for(const file of receipt.files){
15
+ const name=file?.name;
16
+ if(typeof name!=='string'||!name||name.length>255||!/^[A-Za-z0-9_.-]+$/.test(name)||name==='.'||name==='..'||name.endsWith('.')||/^(?:con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(name)||names.has(name.toLowerCase()))throw Error('Invalid or duplicate deferred asset receipt name');
17
+ if(!Number.isSafeInteger(file.bytes)||file.bytes<0||file.bytes>MAX_LAZY_ASSET_BYTES||(total+=file.bytes)>MAX_LAZY_ASSET_BYTES)throw Error('Deferred asset receipt exceeded the 128 MiB generation budget');
18
+ names.add(name.toLowerCase());files.push({name,bytes:file.bytes});
19
+ }
20
+ return files;
21
+ }
22
+
23
+ function regularAsset(info,bytes){
24
+ if(!info.isFile()||info.isSymbolicLink())throw Error('Invalid deferred asset: expected a regular file without symbolic links');
25
+ if(info.size!==bytes)throw Error('Deferred asset does not match the receipt byte size');
26
+ return info;
27
+ }
28
+
29
+ async function readReceiptAsset(filename,bytes,checked){
30
+ const expected=checked||regularAsset(await lstat(filename),bytes);
31
+ const file=await open(filename,constants.O_RDONLY|(constants.O_NOFOLLOW||0));
32
+ try{
33
+ const info=regularAsset(await file.stat(),bytes);
34
+ if(info.dev!==expected.dev||info.ino!==expected.ino)throw Error('Deferred asset changed after receipt validation');
35
+ // Metadata can race a writer. Read only the declared size plus one byte,
36
+ // rather than letting readFile allocate for a later, unbounded file size.
37
+ const data=Buffer.allocUnsafe(bytes);let offset=0;
38
+ while(offset<bytes){
39
+ const {bytesRead}=await file.read(data,offset,bytes-offset,offset);
40
+ if(!bytesRead)throw Error('Deferred asset ended before the receipt byte size');
41
+ offset+=bytesRead;
42
+ }
43
+ if((await file.read(Buffer.allocUnsafe(1),0,1,bytes)).bytesRead)throw Error('Deferred asset exceeded the receipt byte size');
44
+ return data;
45
+ }finally{await file.close();}
46
+ }
47
+
48
+ async function readExistingReceiptAsset(filename,bytes){
49
+ const info=await lstat(filename);
50
+ if(!info.isFile()||info.isSymbolicLink())throw Error('Invalid deferred asset: expected a regular file without symbolic links');
51
+ if(info.size!==bytes)throw Error('Deferred compilation produced a conflicting immutable asset');
52
+ return readReceiptAsset(filename,bytes,info);
53
+ }
54
+
55
+ /** Only registered tokens can reach the compiler. The caller serializes builds. */
56
+ export async function materializeDevImport({root,current,id,compile}){
57
+ const registry=current.manifest.devLazy;
58
+ if(!registry||!registry.modules.some(item=>item.id===id))throw Error('Unknown deferred import identifier');
59
+ if(registry.outputs[id])return registry.outputs[id];
60
+ const built=await compile({id,modules:registry.modules,bridges:registry.bridges,shared:registry.shared});
61
+ const next=built.devLazy,target=next?.outputs?.[id];
62
+ if(!target)throw Error('The compiler did not produce the requested deferred import');
63
+ // New server implementations cannot be added to a running immutable runtime.
64
+ // Existing Server Action proxies are reused through the static ESM bridges.
65
+ if(Object.keys(built.app?.actions||{}).some(key=>!Object.hasOwn(current.manifest.app?.actions||{},key)))throw Error('A deferred module introduced a new Server Action. Import that action statically or disable experimental.lazyDynamicImports.');
66
+ const source=path.join(built.outputDirectory,'assets'),destination=path.join(root,current.directory,'assets');
67
+ const received=Object.hasOwn(built,'devLazyAssets');
68
+ const files=received?receiptFiles(built.devLazyAssets,current.manifest.cacheId):await readdir(source,{withFileTypes:true}),pending=[];let bytes=0;
69
+ if(files.length>MAX_LAZY_ASSET_FILES)throw Error('Deferred import exceeded the asset count budget');
70
+ if(received){
71
+ for(const directory of [source,destination]){const info=await lstat(directory);if(!info.isDirectory()||info.isSymbolicLink())throw Error('Invalid deferred asset directory');}
72
+ // Validate every declared file before reading any payload or publishing.
73
+ for(const file of files)file.info=regularAsset(await lstat(path.join(source,file.name)),file.bytes);
74
+ }
75
+ for(const file of files){
76
+ if(!received&&(!file.isFile()||file.name!==path.basename(file.name)))throw Error('Invalid deferred asset');
77
+ if(!received&&(await stat(path.join(source,file.name))).size>MAX_LAZY_ASSET_BYTES)throw Error('Deferred import asset exceeded the 128 MiB generation budget');
78
+ const data=received?await readReceiptAsset(path.join(source,file.name),file.bytes,file.info):await readFile(path.join(source,file.name));
79
+ const previous=await(received?readExistingReceiptAsset(path.join(destination,file.name),file.bytes):readFile(path.join(destination,file.name))).catch(error=>{if(error.code!=='ENOENT')throw error;});
80
+ if(previous){if(!previous.equals(data))throw Error('Deferred compilation produced a conflicting immutable asset');continue;}
81
+ bytes+=data.length;
82
+ if(bytes+(current.lazyAssetBytes||0)>MAX_LAZY_ASSET_BYTES)throw Error('Deferred import assets exceeded the 128 MiB generation budget');
83
+ pending.push([file.name,data]);
84
+ }
85
+ if(pending.length){
86
+ // Partial writes stay outside the served assets directory. A hard link
87
+ // publishes a complete file atomically and exclusively without replacing an
88
+ // immutable asset, even if another writer wins between inspection and I/O.
89
+ const temporary=await mkdtemp(path.join(root,current.directory,'.lazy-publish-'));
90
+ current.lazyAssetBytes=(current.lazyAssetBytes||0)+bytes;
91
+ let retained=0,failure;
92
+ try{
93
+ for(const [name,data]of pending)await writeFile(path.join(temporary,name),data,{flag:'wx'});
94
+ for(const [name,data]of pending){
95
+ // A failed publication can have an uncertain outcome. Keep its charge
96
+ // conservatively; a retry can reuse a complete asset without recharging.
97
+ retained+=data.length;
98
+ try{await link(path.join(temporary,name),path.join(destination,name));}
99
+ catch(error){
100
+ if(error.code!=='EEXIST')throw error;
101
+ const existing=received?await readExistingReceiptAsset(path.join(destination,name),data.length):await readFile(path.join(destination,name));
102
+ if(!existing.equals(data))throw Error('Deferred compilation produced a conflicting immutable asset');
103
+ retained-=data.length;
104
+ }
105
+ }
106
+ }catch(error){failure=error;}
107
+ try{
108
+ await rm(temporary,{recursive:true,force:true,maxRetries:10,retryDelay:100});
109
+ // Reclaim only unpublished bytes after deletion of our private staging
110
+ // files is confirmed. Complete public files are never removed on failure.
111
+ current.lazyAssetBytes-=bytes-retained;
112
+ }catch(error){
113
+ if(failure){failure.cleanupError=error;failure.message+='; deferred asset staging cleanup also failed';}
114
+ else failure=error;
115
+ }
116
+ if(failure)throw failure;
117
+ }
118
+ registry.modules=next.modules;registry.bridges=next.bridges;registry.shared=next.shared;
119
+ Object.assign(registry.outputs,next.outputs);
120
+ return target;
121
+ }
@@ -0,0 +1,14 @@
1
+ import { lstat, realpath, rm } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ /** Delete only an unpublished direct-child stage created by our lazy compiler. */
5
+ export async function discardLazyBuild(root, built) {
6
+ if (!built?.devLazyTemporary) return;
7
+ const canonicalRoot = await realpath(root), directory = path.resolve(built.outputDirectory);
8
+ if (path.dirname(directory) !== canonicalRoot ||
9
+ !/^\.prnext-build-[a-f0-9-]{36}$/.test(path.basename(directory))) throw Error('Invalid deferred staging directory');
10
+ const info = await lstat(directory).catch(error => { if (error.code !== 'ENOENT') throw error; });
11
+ if (!info) return;
12
+ if (!info.isDirectory() || info.isSymbolicLink() || await realpath(directory) !== directory) throw Error('Invalid deferred staging directory');
13
+ await rm(directory, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 });
14
+ }
@@ -0,0 +1,64 @@
1
+ const globalInput = /(?:^|\/)(?:package(?:-lock)?\.json|pnpm-lock\.yaml|yarn\.lock|(?:tsconfig|jsconfig)\.json|(?:prnext|next|contentlayer|postcss|tailwind)\.config\.|\.env|(?:src\/)?(?:middleware|proxy)\.[^/]+$|pages\/_document\.|(?:global-error|global-not-found)\.)/;
2
+ const generatedRouteFields = new Set(['module', 'client', 'css', 'cssScopes']);
3
+ function stable(value) {
4
+ if (Array.isArray(value)) return value.map(stable);
5
+ if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])]));
6
+ return value;
7
+ }
8
+ const signature = value => JSON.stringify(stable(value));
9
+ const routeContract = manifest => manifest.routes.map(route => Object.fromEntries(Object.entries(route).filter(([key]) => !generatedRouteFields.has(key)))).sort((a, b) => a.id.localeCompare(b.id));
10
+ function actionContract(manifest) {
11
+ const ids = Object.keys(manifest.app?.actions || {}).sort();
12
+ if (!ids.length) return [];
13
+ const metadata = manifest.devActions;
14
+ if (metadata?.version !== 1 || metadata.complete !== true || manifest.devDependencyStatus?.complete !== true || !Array.isArray(metadata.actions)) return;
15
+ const modules = new Set((manifest.devModules || []).map(record => record.route));
16
+ const routed = new Set((manifest.devDependencies || []).flatMap(record => record.files));
17
+ if (signature(metadata.actions.map(action => action.id).sort()) !== signature(ids)) return;
18
+ const owners = new Set();
19
+ for (const action of metadata.actions) {
20
+ const owner = action.file + '\0' + action.binding;
21
+ if (!modules.has(action.file) || !routed.has(action.file) || owners.has(owner) || !Array.isArray(action.runtimes)) return;
22
+ owners.add(owner);
23
+ }
24
+ return [...metadata.actions].sort((a, b) => (a.file + '\0' + a.binding).localeCompare(b.file + '\0' + b.binding));
25
+ }
26
+
27
+ // Both committed graphs participate: removed imports belong to the old graph,
28
+ // newly introduced imports to the new one. No partial/unknown graph is proof.
29
+ export function selectiveDevelopmentRoutes(previous, next, { changed, affectedRoutes, affectedModules = [], nextAffected } = {}) {
30
+ if (!previous || !changed?.length || !Array.isArray(affectedRoutes) || !nextAffected || nextAffected.complete !== true || !Array.isArray(nextAffected.unknown) || nextAffected.unknown.length || !Array.isArray(nextAffected.routes) ||
31
+ previous.devDependencyStatus?.complete === false || next.devDependencyStatus?.complete === false || changed.some(file => globalInput.test(file))) return;
32
+ // The visible URL is not proof of the rendered route under server rewrites,
33
+ // localized routing, or intercepted/parallel branches. Keep these global
34
+ // until the browser can certify every visible route identity.
35
+ for (const manifest of [previous, next]) if (manifest.middleware || manifest.config?.i18n ||
36
+ Object.values(manifest.customRoutes?.rewrites || {}).some(rules => rules.length) ||
37
+ manifest.routes.some(route => route.parallel || route.interception)) return;
38
+ if (signature([previous.config, previous.customRoutes, routeContract(previous)]) !== signature([next.config, next.customRoutes, routeContract(next)])) return;
39
+ const before = actionContract(previous), after = actionContract(next);
40
+ if (!before || !after || previous.app?.actionKey !== next.app?.actionKey ||
41
+ signature(before.map(({id, ...action}) => action)) !== signature(after.map(({id, ...action}) => action))) return;
42
+ const modules = new Set([...affectedModules, ...(nextAffected.modules || [])]);
43
+ for (let i = 0; i < before.length; i++) if (before[i].id !== after[i].id && !modules.has(before[i].file)) return;
44
+ const routes = [...new Set([...affectedRoutes, ...nextAffected.routes])].sort();
45
+ return Buffer.byteLength(JSON.stringify(routes)) <= 32 * 1024 ? routes : undefined;
46
+ }
47
+
48
+ // An ensure/navigation transaction must prepare the new loader even when its
49
+ // destination differs from location.pathname until history is committed.
50
+ export function canSkipDevelopmentUpdate({ confirmed, navigation, ensuring, affectedRoutes, routes, pathname }) {
51
+ if (!confirmed || navigation || ensuring || !Array.isArray(affectedRoutes) || !Array.isArray(routes)) return false;
52
+ let parts; try { parts = pathname.split('/').filter(Boolean).map(decodeURIComponent); } catch { return false; }
53
+ const matches = pattern => {
54
+ const segments = pattern.split('/').filter(Boolean); let index = 0;
55
+ for (const segment of segments) {
56
+ if (/^\[\[\.\.\.[^\]]+\]\]$/.test(segment)) return true;
57
+ if (/^\[\.\.\.[^\]]+\]$/.test(segment)) return index < parts.length;
58
+ if (index >= parts.length || !(segment.startsWith('[') && segment.endsWith(']')) && segment !== parts[index]) return false;
59
+ index++;
60
+ }
61
+ return index === parts.length;
62
+ };
63
+ return routes.some(matches) && !affectedRoutes.some(matches);
64
+ }
@@ -5,11 +5,12 @@ import {fileURLToPath} from 'node:url';
5
5
  import path from 'node:path';
6
6
  import {EventEmitter} from 'node:events';
7
7
 
8
- export function createDevRuntimePool({binary,root,home,environment={},onChild=()=>{}}){
8
+ export function createDevRuntimePool({binary,root,home,environment={},onBeforeChild=()=>{},onChild=()=>{},onReady=()=>{}}){
9
9
  let spare,disposed=false;const children=new Set();
10
10
  function create(){
11
11
  if(disposed)throw Error('Development runtime is closed');
12
12
  const token=randomBytes(32).toString('base64url');
13
+ const ticket=onBeforeChild();
13
14
  const child=spawn(binary,['dev-spare',root,'--node',process.execPath,'--script',fileURLToPath(new URL('./dev-runtime-spare.mjs',import.meta.url))],{cwd:root,windowsHide:true,stdio:['pipe','pipe','pipe'],env:{...process.env,...environment,NODE_ENV:'development',PRNEXT_DEV_SPARE_TOKEN:token}});
14
15
  children.add(child);onChild(child);
15
16
  const messages=new EventEmitter();let buffer='';child.stdout.setEncoding('utf8');child.stdout.on('data',chunk=>{buffer+=chunk;if(buffer.length>65536){child.kill();return;}for(let end;(end=buffer.indexOf('\n'))>=0;){const line=buffer.slice(0,end);buffer=buffer.slice(end+1);try{const message=JSON.parse(line);if(message.type)messages.emit(message.type,message);}catch{}}});
@@ -22,7 +23,7 @@ export function createDevRuntimePool({binary,root,home,environment={},onChild=()
22
23
  const message=value=>{clean();resolve(value);};
23
24
  messages.on(type,message);child.once('exit',exit);child.once('error',fail);
24
25
  });
25
- const ready=wait('warm');ready.catch(()=>{if(spare?.child===child)spare=undefined;void stop(child);});
26
+ const ready=wait('warm').then(info=>{onReady(child,info,ticket);return info;});ready.catch(()=>{if(spare?.child===child)spare=undefined;void stop(child);});
26
27
  return {child,token,ready,wait};
27
28
  }
28
29
  async function stop(child){
@@ -48,7 +48,7 @@ const broker=net.createServer(socket=>{
48
48
  socket.on('close',()=>{const owned=controls.delete(socket);if(owned&&!controls.size&&ready)process.stdin.emit('end');});
49
49
  });
50
50
  broker.maxConnections=16;
51
- broker.listen(0,'127.0.0.1',()=>{broker.unref();announce({type:'warm',port:broker.address().port});});
51
+ broker.listen(0,'127.0.0.1',()=>{broker.unref();announce({type:'warm',port:broker.address().port,pid:process.pid});});
52
52
  let control='';const arm=chunk=>{control+=chunk;if(control.length>8192)process.exit(1);if(!control.includes('\n'))return;process.stdin.off('data',arm);
53
53
  let message;try{message=JSON.parse(control.trim());}catch{process.exit(1);}if(message?.type!=='arm'||armed)process.exit(1);
54
54
  void realpath(message.dist).then(dist=>{if(!dist.startsWith(root+path.sep))throw Error('Generation outside project');armed={dist};announce({type:'armed'});}).catch(()=>process.exit(1));
@@ -0,0 +1,154 @@
1
+ import {randomUUID} from 'node:crypto';
2
+ import {hostname} from 'node:os';
3
+ import {constants, writeFileSync, renameSync, rmSync} from 'node:fs';
4
+ import {lstat, open, opendir, rm} from 'node:fs/promises';
5
+ import path from 'node:path';
6
+ import {captureStorageDirectory,verifyStorageDirectories,sameStoragePath} from './storage-path.mjs';
7
+
8
+ const marker = '.owner.json', disabledMarker = '.recovery-disabled', uuid = /^[a-f\d]{8}(?:-[a-f\d]{4}){3}-[a-f\d]{12}$/;
9
+ const grace = 60_000, maxChildren = 4096, maxMarker = 64 * 1024;
10
+ const alive = pid => {try {process.kill(pid, 0); return true;} catch (error) {return error.code !== 'ESRCH';}};
11
+ const pid = value => Number.isSafeInteger(value) && value > 0;
12
+
13
+ /** A ticket is durable before a spawn. An interrupted spawn/metadata update
14
+ * leaves the session ineligible for automatic recovery, never falsely idle. */
15
+ export function createDevelopmentSessionOwner(home, {session, processId = process.pid, host = hostname(), now = Date.now, warn = console.warn} = {}) {
16
+ if (!uuid.test(session) || path.basename(home) !== session || !pid(processId)) throw Error('Invalid development session owner');
17
+ const state = {version: 1, session, token: randomUUID(), pid: processId, host, createdAt: now(), updatedAt: now(), eligible: true, children: [], pending: []};
18
+ function publish(initial = false) {
19
+ state.updatedAt = now();
20
+ const text = JSON.stringify(state) + '\n';
21
+ if (Buffer.byteLength(text) > maxMarker) throw Error('Development owner metadata exceeds its budget');
22
+ const file = path.join(home, marker);
23
+ if (initial) {writeFileSync(file, text, {flag: 'wx', mode: 0o600}); return;}
24
+ const temporary = file + '.' + randomUUID() + '.tmp';
25
+ try {writeFileSync(temporary, text, {flag: 'wx', mode: 0o600}); renameSync(temporary, file);}
26
+ finally {rmSync(temporary, {force: true});}
27
+ }
28
+ publish(true);
29
+ function disable() {
30
+ // A separate create-only marker also works if Windows has temporarily
31
+ // locked the owner receipt. A spawn may proceed only once this safeguard
32
+ // or its pending ticket is durable.
33
+ try {writeFileSync(path.join(home, disabledMarker), 'Incomplete development child ownership.\n', {flag: 'wx', mode: 0o600});}
34
+ catch (error) {if (error.code !== 'EEXIST') throw error;}
35
+ state.eligible = false; try {publish();} catch { /* The create-only marker forbids cleanup. */ }
36
+ }
37
+ return {
38
+ beforeSpawn() {
39
+ if (!state.eligible) return null;
40
+ if (state.pending.length >= 128 || state.children.length >= maxChildren) {disable(); return null;}
41
+ const ticket = randomUUID(); state.pending.push(ticket); try {publish();} catch {disable();} return ticket;
42
+ },
43
+ spawned(ticket, child, descendants = []) {
44
+ if (!state.eligible) return;
45
+ if (!state.pending.includes(ticket)) {disable(); return;}
46
+ if (!pid(child?.pid) || descendants.some(value => !pid(value))) {state.eligible = false; try {disable();} catch { /* The pre-spawn ticket remains durable. */ } return;}
47
+ for (const value of [child.pid, ...descendants]) if (!state.children.includes(value)) state.children.push(value);
48
+ state.pending = state.pending.filter(value => value !== ticket);
49
+ try {publish();} catch (error) {state.eligible = false; try {disable();} catch { /* The pre-spawn ticket remains durable. */ } warn('Development session recovery disabled: ' + (error.code || 'metadata publication failed'));}
50
+ },
51
+ disable,
52
+ };
53
+ }
54
+
55
+ async function readOwner(home) {
56
+ const name = path.join(home, marker), info = await lstat(name);
57
+ if (!info.isFile() || info.isSymbolicLink() || info.size > maxMarker) return;
58
+ const file = await open(name, constants.O_RDONLY | (constants.O_NOFOLLOW || 0));
59
+ try {
60
+ const bytes = Buffer.alloc(maxMarker + 1), {bytesRead} = await file.read(bytes, 0, bytes.length, 0);
61
+ if (bytesRead > maxMarker) return;
62
+ const text = bytes.toString('utf8', 0, bytesRead), value = JSON.parse(text);
63
+ if (value.version !== 1 || value.session !== path.basename(home) || !uuid.test(value.session) || !uuid.test(value.token) || !pid(value.pid) || typeof value.host !== 'string' || value.host.length > 255 || !Number.isSafeInteger(value.createdAt) || !Number.isSafeInteger(value.updatedAt) || value.updatedAt < value.createdAt || value.eligible !== true || !Array.isArray(value.pending) || value.pending.length || !Array.isArray(value.children) || value.children.length > maxChildren || value.children.some(value => !pid(value))) return;
64
+ return {value, text};
65
+ } finally {await file.close();}
66
+ }
67
+
68
+ async function ordinaryTree(home, budget, snapshots) {
69
+ const directory = await captureStorageDirectory(home);
70
+ if (!directory) return false;
71
+ snapshots.push(directory);
72
+ for await (const entry of await opendir(home)) {
73
+ if (++budget.files > 32_768) return false;
74
+ const file = path.join(home, entry.name), info = await lstat(file);
75
+ if (info.isSymbolicLink()) return false;
76
+ if (info.isDirectory()) {if (!await ordinaryTree(file, budget, snapshots)) return false;}
77
+ else if (!info.isFile() || (budget.bytes += info.size) > 2 * 1024 ** 3) return false;
78
+ }
79
+ return true;
80
+ }
81
+
82
+ /** Called while the project's exclusive development lock is held. Legacy,
83
+ * foreign, active, ambiguous and unmarked sessions are always preserved. */
84
+ export async function pruneDevelopmentSessions(root, {host = hostname(), now = Date.now(), isAlive = alive, graceMs = grace, remove = rm} = {}) {
85
+ const report = {removed: 0, retained: 0, failed: 0};
86
+ // The caller already holds the canonical project's development lock. Do not
87
+ // recanonicalize a replaced root into a different project's storage.
88
+ root = path.resolve(root);
89
+ const container = path.join(root, '.prnext-dev');
90
+ let info;
91
+ try {info = await lstat(container);} catch (error) {if (error.code === 'ENOENT') return report; throw error;}
92
+ if (!info.isDirectory() || info.isSymbolicLink()) return report;
93
+ const ancestors = [await captureStorageDirectory(root), await captureStorageDirectory(container)];
94
+ if (!await verifyStorageDirectories(ancestors)) return report;
95
+ let scanned = 0;
96
+ for await (const entry of await opendir(container)) {
97
+ if (++scanned > 256) break;
98
+ if (!entry.isDirectory() || !uuid.test(entry.name)) {report.retained++; continue;}
99
+ const home = path.join(container, entry.name);
100
+ try {
101
+ const snapshots = [...ancestors, await captureStorageDirectory(home)];
102
+ if (!await verifyStorageDirectories(snapshots)) {report.retained++; continue;}
103
+ const owner = await readOwner(home);
104
+ if (await lstat(path.join(home, disabledMarker)).then(() => true, error => {if (error.code === 'ENOENT') return false; throw error;})) {report.retained++; continue;}
105
+ const inactive = value => value.host === host && value.updatedAt <= now - graceMs && value.createdAt <= now && ![value.pid, ...value.children].some(isAlive);
106
+ if (!owner || !inactive(owner.value) || !await verifyStorageDirectories(snapshots) || !await ordinaryTree(home, {files: 0, bytes: 0}, snapshots) || !await verifyStorageDirectories(snapshots)) {report.retained++; continue;}
107
+ // Validate the same marker and lifetime after the bounded tree walk. A
108
+ // currently running session can never be deleted merely because it is old.
109
+ const current = await readOwner(home), directory = await lstat(home);
110
+ const disabled = await lstat(path.join(home, disabledMarker)).then(() => true, error => {if (error.code === 'ENOENT') return false; throw error;});
111
+ if (disabled || !current || current.text !== owner.text || !inactive(current.value) || !directory.isDirectory() || directory.isSymbolicLink() || !await verifyStorageDirectories(snapshots)) {report.retained++; continue;}
112
+ await remove(home, {recursive: true, force: true, maxRetries: 3, retryDelay: 100}); report.removed++;
113
+ } catch {report.failed++;}
114
+ }
115
+ return report;
116
+ }
117
+
118
+ /** Failed generation cleanup remains in this retryable queue, including the
119
+ * exact completed phase. Nothing is deleted before requests have drained. */
120
+ export function createRetiredGenerationCleanup({root, home, gateway, stop, remove = rm, warn = console.warn}) {
121
+ const pending = new Map(); let running;
122
+ const ownedHome = path.resolve(home);
123
+ const ancestry = directory => [path.resolve(root), path.join(path.resolve(root), '.prnext-dev'), ownedHome, directory];
124
+ async function storage(item, directory) {
125
+ item.snapshots ??= await Promise.all(ancestry(directory).map(captureStorageDirectory));
126
+ return verifyStorageDirectories(item.snapshots);
127
+ }
128
+ async function clean(item) {
129
+ const directory = path.resolve(root, item.generation.directory);
130
+ if (!sameStoragePath(path.dirname(directory), ownedHome) || !uuid.test(path.basename(directory)) || item.generation.id !== path.basename(directory)) throw Error('Invalid retired development generation');
131
+ if (!await storage(item, directory)) throw Error('Development generation storage changed');
132
+ if (item.phase === 0) {await gateway.request('retire', {generation: item.generation.id}); item.phase++;}
133
+ if (!await storage(item, directory)) throw Error('Development generation storage changed');
134
+ if (item.phase === 1) {await item.generation.runtime?.close(); item.phase++;}
135
+ if (item.phase === 2) {await stop(item.generation.child); item.phase++;}
136
+ if (item.phase === 3) {await gateway.request('forget', {generation: item.generation.id}); item.phase++;}
137
+ const snapshots = [...item.snapshots];
138
+ if (!await storage(item, directory) || !await ordinaryTree(directory, {files: 0, bytes: 0}, snapshots) || !await verifyStorageDirectories(snapshots)) throw Error('Development generation storage changed');
139
+ await remove(directory, {recursive: true, force: true, maxRetries: 10, retryDelay: 100});
140
+ }
141
+ const retry = () => {
142
+ if (running) return running;
143
+ running = (async () => {
144
+ for (const [id, item] of [...pending]) {
145
+ try {await clean(item); pending.delete(id);}
146
+ catch (error) {warn('Development generation cleanup: ' + error.message);}
147
+ }
148
+ })().finally(() => {running = undefined;});
149
+ return running;
150
+ };
151
+ return {get pending() {return pending.size;}, enqueue(generation) {if (!pending.has(generation.id)) pending.set(generation.id, {generation, phase: 0}); return retry();}, retry,
152
+ async close() {await running; for (const item of pending.values()) {const directory=path.resolve(root,item.generation.directory);if(await storage(item,directory))await item.generation.runtime?.close();else await stop(item.generation.runtime?.child);await stop(item.generation.child);}},
153
+ };
154
+ }
@@ -0,0 +1,15 @@
1
+ import {rm} from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ // Asset-only generations do not own backend/compiler requests. Drain their
5
+ // asset leases and finish deleting the excess snapshot before announcing ready.
6
+ export async function pruneDevelopmentStyles(generations,gateway,root){
7
+ while(generations.length>3){
8
+ const old=generations[0];
9
+ await gateway.request('retire',{generation:old.id});
10
+ await gateway.request('forget',{generation:old.id});
11
+ await rm(path.join(root,old.directory),{recursive:true,force:true,maxRetries:10,retryDelay:100});
12
+ // Keep failed deletions tracked so a later update can retry their cleanup.
13
+ generations.shift();
14
+ }
15
+ }