@thomas.f/prnext 0.1.16-alpha → 0.1.18-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 (215) hide show
  1. package/AI.md +63 -0
  2. package/README.md +35 -503
  3. package/build/ADAPTERS.md +27 -2
  4. package/build/actions.mjs +6 -1
  5. package/build/analyze.mjs +340 -0
  6. package/build/app-config.mjs +20 -8
  7. package/build/app-static.mjs +41 -14
  8. package/build/app-styles.mjs +79 -0
  9. package/build/app.mjs +99 -31
  10. package/build/build-lock.mjs +6 -6
  11. package/build/bundle-runtime.mjs +39 -11
  12. package/build/cache-components.mjs +1 -1
  13. package/build/cache-handlers.mjs +1 -1
  14. package/build/check.mjs +56 -11
  15. package/build/compact-css.mjs +53 -0
  16. package/build/compilation-cache.mjs +269 -0
  17. package/build/compilation-store.mjs +142 -0
  18. package/build/compiler.mjs +51 -4
  19. package/build/concurrent.mjs +12 -0
  20. package/build/config.mjs +50 -13
  21. package/build/create.mjs +239 -0
  22. package/build/css-modules.mjs +65 -10
  23. package/build/deployment-assets.mjs +89 -0
  24. package/build/deployment-config.mjs +18 -0
  25. package/build/dev-compiler-health.mjs +11 -0
  26. package/build/dev-css-update.mjs +85 -0
  27. package/build/dev-dependencies.mjs +44 -0
  28. package/build/dev-discovery.mjs +28 -0
  29. package/build/dev-worker.mjs +14 -5
  30. package/build/dev.mjs +64 -6
  31. package/build/dynamic.mjs +1 -1
  32. package/build/edge.mjs +5 -4
  33. package/build/fonts.mjs +1 -1
  34. package/build/framework-imports.mjs +50 -0
  35. package/build/html-only-config.mjs +53 -0
  36. package/build/html-only-graph.mjs +67 -0
  37. package/build/incremental.mjs +8 -4
  38. package/build/index.mjs +140 -112
  39. package/build/inspect.mjs +3 -0
  40. package/build/inspection-summary.mjs +37 -4
  41. package/build/instrumentation.mjs +30 -0
  42. package/build/loader-cache.mjs +53 -26
  43. package/build/metadata-routes.mjs +3 -4
  44. package/build/migrate.mjs +60 -57
  45. package/build/migration-audit.mjs +1 -1
  46. package/build/native-type-config.mjs +37 -0
  47. package/build/npm-packages.mjs +3 -18
  48. package/build/package-manager.mjs +308 -0
  49. package/build/persistent-cache.mjs +112 -0
  50. package/build/precompress.mjs +62 -30
  51. package/build/prerender-cache.mjs +70 -0
  52. package/build/prerender-config.mjs +29 -0
  53. package/build/prerender-pages.mjs +100 -0
  54. package/build/prerender-pool.mjs +165 -0
  55. package/build/prerender-process-worker.mjs +58 -0
  56. package/build/prerender-worker.mjs +10 -0
  57. package/build/production-builder.mjs +129 -0
  58. package/build/react-compiler-report.mjs +225 -0
  59. package/build/react-compiler.mjs +139 -0
  60. package/build/runtime-contract.mjs +2 -2
  61. package/build/scan.mjs +4 -4
  62. package/build/source-analysis.mjs +42 -0
  63. package/build/static-adapter.mjs +13 -5
  64. package/build/static-export.mjs +4 -4
  65. package/build/static-input.mjs +48 -0
  66. package/build/styles.mjs +17 -3
  67. package/build/transform-cache.mjs +37 -4
  68. package/build/transform.mjs +1 -1
  69. package/build/type-command.mjs +41 -0
  70. package/build/typed-routes.mjs +95 -0
  71. package/build/typescript-dev-worker.mjs +18 -0
  72. package/build/typescript-dev.mjs +49 -0
  73. package/build/typescript-native-worker.mjs +55 -0
  74. package/build/typescript-native.mjs +18 -0
  75. package/build/typescript-toolchain.mjs +19 -0
  76. package/build/typescript-watch.mjs +22 -0
  77. package/build/typescript.mjs +11 -8
  78. package/build/webpack-graph.mjs +170 -30
  79. package/build/worker-memory.mjs +9 -0
  80. package/build/worker-preload.mjs +9 -0
  81. package/cli.mjs +162 -122
  82. package/compat/activity-cache.cjs +26 -0
  83. package/compat/after.cjs +6 -4
  84. package/compat/app-layout-context.cjs +52 -2
  85. package/compat/base64.cjs +10 -0
  86. package/compat/cache-handlers.cjs +12 -2
  87. package/compat/cache-wire.cjs +28 -0
  88. package/compat/cache.cjs +2 -1
  89. package/compat/cache.d.cts +4 -0
  90. package/compat/cookies.cjs +21 -5
  91. package/compat/data-cache.cjs +12 -8
  92. package/compat/flight-delta.cjs +150 -0
  93. package/compat/headers.cjs +4 -2
  94. package/compat/incremental-cache.cjs +20 -7
  95. package/compat/index.d.ts +89 -10
  96. package/compat/link.cjs +12 -9
  97. package/compat/link.d.cts +4 -3
  98. package/compat/navigation-authorization.cjs +9 -0
  99. package/compat/navigation-stages.cjs +19 -0
  100. package/compat/navigation.d.cts +4 -3
  101. package/compat/offline-store.cjs +11 -0
  102. package/compat/offline.cjs +5 -0
  103. package/compat/offline.d.cts +2 -0
  104. package/compat/redis-cache.cjs +201 -0
  105. package/compat/redis-cache.d.cts +37 -0
  106. package/compat/router.d.cts +4 -3
  107. package/compat/server-primitives.cjs +31 -11
  108. package/compat/server.d.cts +15 -2
  109. package/compat/static-generation.cjs +7 -0
  110. package/compat/typed-route.d.cts +17 -0
  111. package/compat/use-cache.cjs +3 -0
  112. package/docs/ai/compatibility.md +275 -0
  113. package/docs/ai/data.md +288 -0
  114. package/docs/ai/deployment.md +269 -0
  115. package/docs/ai/manifest.json +11 -0
  116. package/docs/ai/quickstart.md +171 -0
  117. package/docs/ai/routing.md +214 -0
  118. package/docs/ai/troubleshooting.md +155 -0
  119. package/index.mjs +2 -0
  120. package/native/diagnostics.mjs +34 -0
  121. package/native/resolve.mjs +7 -2
  122. package/package.json +44 -7
  123. package/release/ai-docs.mjs +48 -0
  124. package/release/verify.mjs +2 -0
  125. package/runtime/action-client.mjs +10 -4
  126. package/runtime/action-server.mjs +2 -0
  127. package/runtime/ai-docs.mjs +202 -0
  128. package/runtime/app-client.mjs +226 -68
  129. package/runtime/app-content.mjs +4 -3
  130. package/runtime/app-partial-client.mjs +3 -0
  131. package/runtime/app-partial.mjs +1 -1
  132. package/runtime/app-prefetch-constants.mjs +4 -0
  133. package/runtime/app-prefetch.mjs +2 -3
  134. package/runtime/app-render-key.mjs +5 -0
  135. package/runtime/app-render.mjs +100 -28
  136. package/runtime/app-routing.mjs +22 -9
  137. package/runtime/app-static.mjs +18 -8
  138. package/runtime/app-stream-html.mjs +10 -53
  139. package/runtime/app-style-selection.mjs +34 -0
  140. package/runtime/app-styles.mjs +154 -0
  141. package/runtime/cache-components-rsc.mjs +4 -1
  142. package/runtime/cache-request.mjs +2 -1
  143. package/runtime/cloud/archive.mjs +5 -6
  144. package/runtime/compile-cache.mjs +8 -0
  145. package/runtime/deployment-client.mjs +19 -0
  146. package/runtime/dev-ai-brands.mjs +26 -0
  147. package/runtime/dev-ai-style.mjs +99 -0
  148. package/runtime/dev-build-reasons.mjs +25 -0
  149. package/runtime/dev-cache-control.mjs +53 -0
  150. package/runtime/dev-client.mjs +245 -46
  151. package/runtime/dev-compiler-broker.mjs +42 -0
  152. package/runtime/dev-console-copy.mjs +19 -0
  153. package/runtime/dev-console.mjs +106 -0
  154. package/runtime/dev-dependency-graph.mjs +40 -0
  155. package/runtime/dev-element-picker-style.mjs +27 -0
  156. package/runtime/dev-element-picker.mjs +213 -0
  157. package/runtime/dev-error-overlay-style.mjs +41 -0
  158. package/runtime/dev-error-overlay.mjs +167 -0
  159. package/runtime/dev-error-report.mjs +121 -0
  160. package/runtime/dev-file-changes.mjs +1 -1
  161. package/runtime/dev-gateway.mjs +44 -0
  162. package/runtime/dev-host-relay.mjs +66 -0
  163. package/runtime/dev-indicator.mjs +191 -0
  164. package/runtime/dev-issue-motion.mjs +214 -0
  165. package/runtime/dev-lazy-tool.mjs +34 -0
  166. package/runtime/dev-logo.mjs +2 -0
  167. package/runtime/dev-notification.mjs +81 -0
  168. package/runtime/dev-popout.mjs +34 -0
  169. package/runtime/dev-position.mjs +107 -0
  170. package/runtime/dev-preview-touch.mjs +122 -0
  171. package/runtime/dev-runtime-pool.mjs +45 -0
  172. package/runtime/dev-runtime-spare.mjs +57 -0
  173. package/runtime/dev-tools-client.mjs +155 -0
  174. package/runtime/dev-tools-mcp.mjs +126 -0
  175. package/runtime/dev-tools-metrics.mjs +31 -0
  176. package/runtime/dev-tools-model.mjs +42 -0
  177. package/runtime/dev-tools-panel.mjs +341 -0
  178. package/runtime/dev-tools-result.mjs +37 -0
  179. package/runtime/dev-tools-server.mjs +256 -0
  180. package/runtime/dev-tools-source.mjs +104 -0
  181. package/runtime/dev-tooltip.mjs +94 -0
  182. package/runtime/dev-ui-icons.mjs +8 -0
  183. package/runtime/dev-workbench-style.mjs +106 -0
  184. package/runtime/dev-workbench.mjs +428 -0
  185. package/runtime/dev-worker-logs.mjs +31 -0
  186. package/runtime/development.mjs +324 -0
  187. package/runtime/document.mjs +1 -1
  188. package/runtime/edge.mjs +6 -5
  189. package/runtime/env.mjs +2 -1
  190. package/runtime/fetch-cache.mjs +10 -8
  191. package/runtime/flight-delta.mjs +50 -0
  192. package/runtime/flight-tap.mjs +28 -0
  193. package/runtime/html-only.mjs +27 -0
  194. package/runtime/http.mjs +2 -0
  195. package/runtime/incremental-cache.mjs +25 -4
  196. package/runtime/instrumentation.mjs +49 -0
  197. package/runtime/navigation-recovery.mjs +36 -0
  198. package/runtime/navigation.mjs +7 -0
  199. package/runtime/node-html-stream.mjs +47 -0
  200. package/runtime/pages-client.mjs +24 -8
  201. package/runtime/pages-navigation.mjs +54 -64
  202. package/runtime/persistent/client.mjs +6 -5
  203. package/runtime/persistent/daemon.mjs +66 -56
  204. package/runtime/persistent/native-proxy.mjs +63 -0
  205. package/runtime/prefetch-scheduler.mjs +110 -0
  206. package/runtime/preload.mjs +12 -0
  207. package/runtime/profile.mjs +14 -0
  208. package/runtime/profiles.json +2 -2
  209. package/runtime/render.mjs +3 -0
  210. package/runtime/route-pattern.mjs +44 -0
  211. package/runtime/rsc-worker.mjs +41 -12
  212. package/runtime/trace.mjs +78 -0
  213. package/runtime/typed-route.mjs +19 -0
  214. package/runtime/watch-project.mjs +29 -18
  215. package/runtime/worker.mjs +102 -24
@@ -0,0 +1,308 @@
1
+ import path from 'node:path';
2
+ import { lstat, readFile, realpath, access, readdir, writeFile, rm, mkdir } from 'node:fs/promises';
3
+ import { constants } from 'node:fs';
4
+ import { execFile, spawn } from 'node:child_process';
5
+ import { randomUUID } from 'node:crypto';
6
+ import { promisify } from 'node:util';
7
+ import picomatch from 'picomatch';
8
+ import { npmCommand } from '../native/npm.mjs';
9
+ import { rename } from '../runtime/fs.mjs';
10
+ import { pathToFileURL } from 'node:url';
11
+ import { packageManifest } from '../native/resolve.mjs';
12
+
13
+ const execute = promisify(execFile);
14
+ export const migrationLockfiles = ['package-lock.json','npm-shrinkwrap.json','pnpm-lock.yaml','yarn.lock','bun.lock','bun.lockb'];
15
+ const lockManager = name => name.startsWith('pnpm-') ? 'pnpm' : name === 'yarn.lock' ? 'yarn' : name.startsWith('bun.') ? 'bun' : 'npm';
16
+ const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
17
+ const inside = (root, file) => { const relative = path.relative(root, file); return relative === '' || !relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative); };
18
+
19
+ export async function regularMigrationFile(file, { limit = 64 * 1024 * 1024 } = {}) {
20
+ let info;
21
+ try { info = await lstat(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
22
+ if (!info.isFile() || info.isSymbolicLink()) throw new Error(`Migration requires a regular file, not a symlink or directory: ${file}`);
23
+ if (info.size > limit) throw new Error(`Migration file exceeds ${limit} bytes: ${file}`);
24
+ const contents = await readFile(file);
25
+ if (contents.length > limit) throw new Error(`Migration file exceeds ${limit} bytes: ${file}`);
26
+ return { contents, mode: info.mode & 0o777 };
27
+ }
28
+
29
+ function yamlString(value) {
30
+ value = value.trim();
31
+ if (value.startsWith('"')) {
32
+ const match = /^("(?:[^"\\]|\\.)*")(?:\s+#.*)?$/.exec(value);
33
+ if (!match) throw new Error('Unsupported YAML string in package-manager configuration.');
34
+ return JSON.parse(match[1]);
35
+ }
36
+ if (value.startsWith("'")) {
37
+ const match = /^'((?:[^']|'')*)'(?:\s+#.*)?$/.exec(value);
38
+ if (!match) throw new Error('Unsupported YAML string in package-manager configuration.');
39
+ return match[1].replaceAll("''", "'");
40
+ }
41
+ const result = value.replace(/\s+#.*$/, '').trim();
42
+ if (!result || /[\[\]{},&*|>]/.test(result) && !/^[!./\w@*?{}[\]()-]+$/.test(result)) throw new Error('Unsupported YAML workspace pattern; use a list of quoted package globs.');
43
+ return result;
44
+ }
45
+ function pnpmPatterns(source) {
46
+ const lines = source.split(/\r?\n/), start = lines.findIndex(line => /^packages\s*:/.test(line));
47
+ if (start < 0) return ['**'];
48
+ const inline = lines[start].replace(/^packages\s*:\s*/, '').replace(/\s+#.*$/, '').trim();
49
+ if (inline) {
50
+ if (!inline.startsWith('[') || !inline.endsWith(']')) throw new Error('pnpm-workspace.yaml packages must be a list of package globs.');
51
+ const entries = inline.slice(1,-1).match(/"(?:[^"\\]|\\.)*"|'(?:[^']|'')*'|[^,]+/g) || [];
52
+ return entries.map(yamlString);
53
+ }
54
+ const result = [];
55
+ for (let index = start + 1; index < lines.length; index++) {
56
+ const line = lines[index]; if (!line.trim() || /^\s*#/.test(line)) continue;
57
+ if (/^\S/.test(line)) break;
58
+ const match = /^\s+-\s+(.+?)\s*$/.exec(line);
59
+ if (!match) throw new Error('pnpm-workspace.yaml packages must be a list of package globs.');
60
+ result.push(yamlString(match[1]));
61
+ }
62
+ return result;
63
+ }
64
+ function workspacePatterns(pkg, pnpm) {
65
+ const patterns = pnpm ? pnpmPatterns(pnpm.contents.toString('utf8')) : Array.isArray(pkg?.workspaces) ? pkg.workspaces : pkg?.workspaces?.packages;
66
+ if (!Array.isArray(patterns) || patterns.some(value => typeof value !== 'string')) throw new Error('Workspace packages must be an array of globs.');
67
+ if (patterns.some(value => !value || value.length > 4096 || /[\0\r\n\\]/.test(value) || path.isAbsolute(value.replace(/^!/, '')) || value.includes('..'))) throw new Error('Workspace package globs must stay inside the workspace root.');
68
+ return patterns;
69
+ }
70
+ function workspaceContains(patterns, relative) {
71
+ const positive = patterns.filter(value => !value.startsWith('!'));
72
+ const negative = patterns.filter(value => value.startsWith('!')).map(value => value.slice(1));
73
+ return positive.some(value => picomatch(value.replace(/\/$/, ''), { dot: true })(relative)) && !negative.some(value => picomatch(value.replace(/\/$/, ''), { dot: true })(relative));
74
+ }
75
+ function workspacePrefix(patterns, relative) {
76
+ return patterns.filter(value => !value.startsWith('!')).some(pattern => {
77
+ // Complex extglob/brace patterns may include slashes. Conservatively reject
78
+ // a linked directory rather than omit manifests the manager could visit.
79
+ if (/[{}()]/.test(pattern)) return true;
80
+ const parts = pattern.replace(/^\.\//, '').replace(/\/$/, '').split('/'), segments = relative.split('/'), memo = new Map();
81
+ function match(p, s) {
82
+ if (s === segments.length) return true;
83
+ if (p === parts.length) return false;
84
+ const key = `${p}:${s}`; if (memo.has(key)) return memo.get(key);
85
+ const result = parts[p] === '**' ? match(p + 1, s) || match(p, s + 1) : picomatch(parts[p], { dot: true })(segments[s]) && match(p + 1, s + 1);
86
+ memo.set(key, result); return result;
87
+ }
88
+ return match(0,0);
89
+ });
90
+ }
91
+ function declaration(pkg) {
92
+ if (pkg?.packageManager === undefined) return null;
93
+ const match = /^(npm|pnpm|yarn|bun)@(\d+\.\d+\.\d+(?:-[\w.-]+)?)(?:\+[^\s]+)?$/.exec(pkg.packageManager);
94
+ if (!match) throw new Error('packageManager must identify npm, pnpm or yarn with an exact version.');
95
+ return { name: match[1], version: match[2] };
96
+ }
97
+
98
+ /** Discover the manager and shared workspace lockfile without running commands. */
99
+ export async function migrationInstallContext(directory, pkg) {
100
+ const target = await realpath(directory), ancestors = [];
101
+ for (let current = target; ; current = path.dirname(current)) {
102
+ const manifest = current === target ? pkg : await regularMigrationFile(path.join(current, 'package.json'), { limit: 1024 * 1024 }).then(value => value ? JSON.parse(value.contents.toString('utf8').replace(/^\uFEFF/, '')) : null);
103
+ const pnpm = await regularMigrationFile(path.join(current, 'pnpm-workspace.yaml'), { limit: 1024 * 1024 });
104
+ ancestors.push({ directory: current, manifest, pnpm });
105
+ if (manifest?.workspaces || pnpm || current === path.dirname(current)) break;
106
+ if (ancestors.length > 64) throw new Error('Workspace discovery exceeds 64 parent directories.');
107
+ }
108
+ const owner = ancestors.at(-1), workspace = Boolean(owner.manifest?.workspaces || owner.pnpm);
109
+ const root = workspace ? owner.directory : target;
110
+ const relative = path.relative(root, target).replaceAll(path.sep, '/');
111
+ let reason;
112
+ const declared = ancestors.map(item => declaration(item.manifest)).filter(Boolean);
113
+ if (new Set(declared.map(item => `${item.name}@${item.version}`)).size > 1) reason = 'Application and workspace packageManager declarations disagree. Align them or use --no-install.';
114
+ const stateFiles = [...new Set([path.join(target, 'package.json'), path.join(root, 'package.json'), ...[root, ...(root !== target ? [target] : [])].flatMap(dir => migrationLockfiles.map(name => path.join(dir, name)))])];
115
+ const existing = [];
116
+ for (const file of stateFiles) if (await regularMigrationFile(file)) existing.push(file);
117
+ const foundLocks = existing.filter(file => migrationLockfiles.includes(path.basename(file)));
118
+ const managers = new Set(foundLocks.map(file => lockManager(path.basename(file))));
119
+ const yarnConfig = await regularMigrationFile(path.join(root, '.yarnrc.yml'), { limit: 1024 * 1024 });
120
+ const classicConfig = await regularMigrationFile(path.join(root, '.yarnrc'), { limit: 1024 * 1024 });
121
+ const manager = declared[0]?.name || (managers.size === 1 ? [...managers][0] : owner.pnpm ? 'pnpm' : yarnConfig || classicConfig ? 'yarn' : 'npm');
122
+ const patterns = workspace ? workspacePatterns(owner.manifest, manager === 'pnpm' || !owner.manifest?.workspaces ? owner.pnpm : null) : [];
123
+ if (workspace && relative && !workspaceContains(patterns, relative)) reason = 'The application is not listed in its parent workspace. Add it to the workspace or use --no-install and install it explicitly.';
124
+ if (workspace && owner.pnpm && manager !== 'pnpm' && !owner.manifest?.workspaces) reason = 'Only pnpm-workspace.yaml declares this workspace, but a different manager was selected. Align the manager or use --no-install.';
125
+ if (manager === 'bun') reason = 'Bun migration installation is not supported. Use --no-install and install with Bun.';
126
+ if (managers.size > 1 || managers.size && !managers.has(manager)) reason = 'Conflicting package-manager lockfiles or packageManager declaration. Keep the intended manager and lockfile, or use --no-install.';
127
+ if (root !== target && foundLocks.some(file => path.dirname(file) === target)) reason = 'A workspace application has a nested lockfile. Resolve the shared/nested lockfile choice or use --no-install.';
128
+ const filterPackage = typeof pkg.name === 'string' && pkg.name.length <= 214 && /^(?:@[a-zA-Z0-9][\w.-]*\/)?[a-zA-Z0-9][\w.-]*$/.test(pkg.name) && !pkg.name.includes('...') ? pkg.name : null;
129
+ if (manager === 'pnpm' && workspace && !filterPackage) reason = 'A pnpm workspace application needs a valid package name for an exact install filter. Set its name or use --no-install.';
130
+ const yarnLock = foundLocks.find(file => path.basename(file) === 'yarn.lock');
131
+ const berry = manager === 'yarn' && (declared[0] ? Number(declared[0].version.split('.')[0]) >= 2 : yarnConfig ? true : yarnLock ? /^__metadata:/m.test((await regularMigrationFile(yarnLock)).contents.toString('utf8')) : false);
132
+ let args;
133
+ if (manager === 'npm') args = workspace && relative ? ['install','--workspace',relative,'--include-workspace-root=false'] : ['install','--workspaces=false'];
134
+ else if (manager === 'pnpm') args = [...(workspace ? ['--filter',filterPackage || 'SET_PACKAGE_NAME','--fail-if-no-match', ...(relative ? [] : ['--include-workspace-root'])] : []),'install','--no-frozen-lockfile'];
135
+ else args = ['install'];
136
+ return { allowed: !reason, ...(reason ? { reason } : {}), manager, version: declared[0]?.version || null, berry, root, target,
137
+ workspace, workspacePatterns: patterns, filterPackage, workspaceTarget: relative || '.', locks: foundLocks, stateFiles,
138
+ command: { name: manager, args, cwd: root }, notes: manager === 'yarn' && workspace ? ['Yarn installs the shared workspace dependency graph from its root; migration edits only the selected application manifest.'] : [] };
139
+ }
140
+
141
+ async function managerLauncher(context, env) {
142
+ if (context.manager === 'npm') return npmCommand(env);
143
+ if (context.manager === 'yarn') {
144
+ const config = await regularMigrationFile(path.join(context.root, '.yarnrc.yml'), { limit: 1024 * 1024 });
145
+ const match = config && /^yarnPath\s*:\s*(.+)$/m.exec(config.contents.toString('utf8'));
146
+ if (match) {
147
+ const file = path.resolve(context.root, yamlString(match[1]));
148
+ let physical;
149
+ try { physical = await realpath(file); } catch { throw new Error('yarnPath must point to a regular release file inside the workspace.'); }
150
+ if (!inside(context.root, file) || !inside(context.root, physical) || !(await regularMigrationFile(physical))) throw new Error('yarnPath must point to a regular release file inside the workspace.');
151
+ return { command: process.execPath, args: [physical] };
152
+ }
153
+ }
154
+ const executable = env.npm_execpath;
155
+ const launcherFile = async file => {
156
+ try { const physical = await realpath(file); return await regularMigrationFile(physical) ? physical : null; }
157
+ catch (error) { if (['ENOENT','ENOTDIR','EACCES','EPERM'].includes(error.code)) return null; throw error; }
158
+ };
159
+ if (executable && new RegExp(`(?:^|[/\\\\])${context.manager}(?:-cli)?\\.(?:c?js)$`).test(executable)) {
160
+ const physical = await launcherFile(executable); if (physical) return { command: process.execPath, args: [physical] };
161
+ }
162
+ const directories = [path.dirname(process.execPath), ...(env.PATH || env.Path || '').split(path.delimiter)].filter(Boolean);
163
+ const candidates = directories.flatMap(directory => [
164
+ path.join(directory, 'node_modules', context.manager, 'bin', context.manager === 'pnpm' ? 'pnpm.cjs' : 'yarn.js'),
165
+ path.join(directory, 'node_modules/corepack/dist', `${context.manager}.js`),
166
+ ]);
167
+ for (const file of candidates) { const physical = await launcherFile(file); if (physical) return { command: process.execPath, args: [physical] }; }
168
+ if (process.platform !== 'win32') return { command: context.manager, args: [] };
169
+ throw new Error(`Cannot locate ${context.manager}. Install the project's declared package manager or provide Corepack, then retry. No global installation was attempted.`);
170
+ }
171
+
172
+ async function workspaceManifestSnapshots(context) {
173
+ const snapshots = new Map(); let directories = 0, bytes = 0;
174
+ const add = async file => {
175
+ if (snapshots.has(file)) return;
176
+ const value = await regularMigrationFile(file, { limit: 1024 * 1024 }); if (!value) return;
177
+ bytes += value.contents.length;
178
+ if (snapshots.size >= 5000 || bytes > 32 * 1024 * 1024) throw new Error('Workspace manifest snapshot exceeds 5000 manifests or 32 MiB.');
179
+ snapshots.set(file, value);
180
+ };
181
+ await add(path.join(context.root, 'package.json')); await add(path.join(context.target, 'package.json'));
182
+ if (context.workspace) {
183
+ const ignored = new Set(['node_modules','.git','.yarn','.corepack','.npm-cache','.pnpm-store','.yarn-cache']);
184
+ async function walk(directory) {
185
+ if (++directories > 10000) throw new Error('Workspace discovery exceeds 10,000 directories.');
186
+ for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a,b) => a.name.localeCompare(b.name))) {
187
+ if (ignored.has(entry.name)) continue;
188
+ const file = path.join(directory, entry.name), relative = path.relative(context.root, file).replaceAll(path.sep, '/');
189
+ if (entry.isSymbolicLink()) {
190
+ if (workspacePrefix(context.workspacePatterns, relative)) throw new Error('Workspace package directories or their ancestors must not be symlinks during migration.');
191
+ continue;
192
+ }
193
+ if (!entry.isDirectory()) continue;
194
+ if (workspaceContains(context.workspacePatterns, relative)) await add(path.join(file, 'package.json'));
195
+ await walk(file);
196
+ }
197
+ }
198
+ await walk(context.root);
199
+ }
200
+ return snapshots;
201
+ }
202
+ const canonical = value => Array.isArray(value) ? value.map(canonical) : object(value) ? Object.fromEntries(Object.keys(value).sort().map(key => [key, canonical(value[key])])) : value;
203
+ async function restoreManifestFormatting(snapshots) {
204
+ let restored = 0; const errors = [];
205
+ for (const [file, before] of snapshots) {
206
+ try {
207
+ if (await realpath(path.dirname(file)) !== path.dirname(file)) throw new Error(`Workspace directory changed during installation: ${path.dirname(file)}`);
208
+ const after = await regularMigrationFile(file, { limit: 1024 * 1024 });
209
+ if (!after) throw new Error(`Workspace manifest disappeared during installation: ${file}`);
210
+ if (after.contents.equals(before.contents)) continue;
211
+ const parse = contents => { try { return JSON.parse(contents.toString('utf8').replace(/^\uFEFF/, '')); } catch { throw new Error(`Workspace manifest became invalid during installation; preserved the newer file: ${file}`); } };
212
+ if (JSON.stringify(canonical(parse(before.contents))) !== JSON.stringify(canonical(parse(after.contents)))) throw new Error(`Workspace manifest changed semantically during installation; preserved the newer file: ${file}`);
213
+ const temporary = file + `.prnext-${randomUUID()}.tmp`;
214
+ try {
215
+ await writeFile(temporary, before.contents, { flag: 'wx', mode: before.mode });
216
+ if (!(await regularMigrationFile(file)).contents.equals(after.contents)) throw new Error(`Workspace manifest changed while restoring formatting: ${file}`);
217
+ await rename(temporary, file); restored++;
218
+ } finally { await rm(temporary, { force: true }); }
219
+ } catch (error) { errors.push(error.message); }
220
+ }
221
+ if (errors.length) throw new Error(errors.join('\n'));
222
+ return restored;
223
+ }
224
+
225
+ // Yarn Classic does not create bin links for link: packages. Preserve the live
226
+ // local checkout and supply scoped launchers without a global `yarn link`.
227
+ async function localClassicBins(context) {
228
+ const application = JSON.parse((await regularMigrationFile(path.join(context.target, 'package.json'))).contents.toString('utf8').replace(/^\uFEFF/, ''));
229
+ if (!application.dependencies?.[packageManifest.name]?.startsWith('link:')) return;
230
+ let installed;
231
+ for (const directory of [context.target, context.root]) {
232
+ const candidate = path.join(directory, 'node_modules', packageManifest.name, 'package.json');
233
+ try { installed = { directory: path.dirname(await realpath(candidate)), manifest: JSON.parse((await regularMigrationFile(await realpath(candidate))).contents.toString('utf8').replace(/^\uFEFF/, '')) }; break; }
234
+ catch (error) { if (error.code !== 'ENOENT') throw error; }
235
+ }
236
+ if (!installed || installed.manifest.name !== packageManifest.name) throw new Error('The local Yarn link did not resolve the PRNext package.');
237
+ let binDirectory = context.target;
238
+ for (const part of ['node_modules','.bin']) {
239
+ binDirectory = path.join(binDirectory, part);
240
+ try { const info = await lstat(binDirectory); if (!info.isDirectory() || info.isSymbolicLink()) throw new Error('Local bin launchers require real node_modules/.bin directories.'); }
241
+ catch (error) { if (error.code !== 'ENOENT') throw error; await mkdir(binDirectory); }
242
+ }
243
+ const create = async (file, contents, mode, source) => {
244
+ // Never overwrite an existing manager/user executable or symbolic link.
245
+ if (await realpath(binDirectory) !== binDirectory) throw new Error('The local bin directory changed during migration.');
246
+ try {
247
+ const info = await lstat(file);
248
+ if (info.isSymbolicLink() && await realpath(file) === source || info.isFile() && (await readFile(file)).equals(Buffer.from(contents))) return;
249
+ throw new Error(`Existing ${path.basename(file)} launcher was preserved; resolve the conflicting executable before migrating.`);
250
+ } catch (error) { if (error.code !== 'ENOENT') throw error; }
251
+ await writeFile(file, contents, { flag: 'wx', mode });
252
+ };
253
+ for (const name of ['prn','prnext']) {
254
+ const entry = installed.manifest.bin?.[name];
255
+ if (typeof entry !== 'string' || path.isAbsolute(entry)) throw new Error(`The local package has no safe ${name} bin entry.`);
256
+ const source = await realpath(path.resolve(installed.directory, entry));
257
+ if (!inside(installed.directory, source) || !(await regularMigrationFile(source))) throw new Error('The local PRNext bin must stay inside its package.');
258
+ const shim = '#!/usr/bin/env node\n' + `import(${JSON.stringify(pathToFileURL(source).href)}).catch(error=>{console.error(error);process.exitCode=1;});\n`;
259
+ await create(path.join(binDirectory, name), shim, 0o755, source);
260
+ if (process.platform === 'win32') {
261
+ const node = process.execPath.replaceAll('%', '%%');
262
+ await create(path.join(binDirectory, `${name}.cmd`), `@ECHO off\r\nSETLOCAL DisableDelayedExpansion\r\n"${node}" "%~dp0${name}" %*\r\n`, 0o644, source);
263
+ }
264
+ }
265
+ }
266
+
267
+ export async function executeManager(context, args, options = {}) {
268
+ const env = { ...process.env, COREPACK_ENABLE_AUTO_PIN: '0', ...(context.manager === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {}), ...options.env };
269
+ const launcher = options.launcher || await managerLauncher(context, env);
270
+ return execute(launcher.command, [...launcher.args, ...args], { cwd: context.root, windowsHide: true, maxBuffer: 4 * 1024 * 1024, ...options, env });
271
+ }
272
+
273
+ export async function installWithManager(context, { json = false, local = false, env: overrides } = {}) {
274
+ if (!context.allowed) throw new Error(context.reason || 'The package-manager installation plan is blocked.');
275
+ const env = { ...process.env, ...overrides, COREPACK_ENABLE_AUTO_PIN: '0', ...(context.manager === 'yarn' ? { YARN_ENABLE_IMMUTABLE_INSTALLS: 'false' } : {}) };
276
+ const launcher = await managerLauncher(context, env);
277
+ const manifests = await workspaceManifestSnapshots(context);
278
+ if (context.manager === 'pnpm' && context.workspace) {
279
+ const matches = [...manifests].filter(([,value]) => JSON.parse(value.contents.toString('utf8').replace(/^\uFEFF/, '')).name === context.filterPackage);
280
+ if (matches.length !== 1 || matches[0][0] !== path.join(context.target, 'package.json')) throw new Error('The pnpm install filter must identify exactly the selected workspace; package names must be unique.');
281
+ }
282
+ const actual = (await executeManager(context, ['--version'], { env, launcher })).stdout.trim();
283
+ if (!/^\d+\.\d+\.\d+(?:-[\w.-]+)?$/.test(actual)) throw new Error(`${context.manager} returned an invalid version.`);
284
+ if (context.version && actual !== context.version) throw new Error(`${context.manager}@${context.version} is declared, but ${actual} would run. Use the declared package manager; no version override was applied.`);
285
+ const args = [...context.command.args];
286
+ if (context.manager === 'npm' && local) args.push('--install-links=false');
287
+ if (context.manager === 'yarn' && Number(actual.split('.')[0]) === 1) args.push('--non-interactive');
288
+ let restoredManifests = 0;
289
+ try { await new Promise((resolve, reject) => {
290
+ const child = spawn(launcher.command, [...launcher.args, ...args], { cwd: context.root, env, stdio: json ? ['inherit', 2, 2] : 'inherit', windowsHide: true });
291
+ child.once('error', reject);
292
+ child.once('exit', (code, signal) => code === 0 ? resolve() : reject(new Error(`${context.manager} install failed (${signal || code}). Fix the dependency conflict and retry. No force or peer-dependency bypass was used.`)));
293
+ }); } finally { restoredManifests = await restoreManifestFormatting(manifests); }
294
+ if (local && context.manager === 'yarn' && Number(actual.split('.')[0]) === 1) await localClassicBins(context);
295
+ const pnp = context.manager === 'yarn' && await regularMigrationFile(path.join(context.root, '.pnp.cjs'));
296
+ for (const name of ['prn','prnext']) {
297
+ if (pnp) {
298
+ const result = await executeManager(context, ['bin', name], { cwd: context.target, env, launcher });
299
+ if (!result.stdout.trim()) throw new Error(`Yarn did not expose the ${name} executable in this workspace.`);
300
+ } else {
301
+ const suffix = process.platform === 'win32' ? '.cmd' : '';
302
+ let found = false;
303
+ for (const directory of [context.target, context.root]) try { await access(path.join(directory, 'node_modules/.bin', name + suffix), process.platform === 'win32' ? constants.F_OK : constants.X_OK); found = true; break; } catch {}
304
+ if (!found) throw new Error(`${context.manager} did not install the ${name} executable. Check bin linking and the PRNext dependency.`);
305
+ }
306
+ }
307
+ return { version: actual, pnp: Boolean(pnp), restoredManifests };
308
+ }
@@ -0,0 +1,112 @@
1
+ // Private, bounded, best-effort caches. A missing/corrupt cache never changes
2
+ // build correctness; only framework-owned pure outputs may be stored here.
3
+ import {createHash,randomUUID} from 'node:crypto';
4
+ import {constants as fsConstants} from 'node:fs';
5
+ import {open,lstat,mkdir,realpath,readFile,readdir,rename,unlink} from 'node:fs/promises';
6
+ import path from 'node:path';
7
+ import {fileURLToPath} from 'node:url';
8
+ import {createRequire} from 'node:module';
9
+
10
+ export const cacheHash=value=>createHash('sha256').update(value).digest('hex');
11
+ const packageRoot=fileURLToPath(new URL('../',import.meta.url)),require=createRequire(import.meta.url);
12
+ const MAX_DISK=64*1024*1024;
13
+ let frameworkIdentity;
14
+ async function frameworkHash(){
15
+ frameworkIdentity??=(async()=>{
16
+ const files=(await readdir(path.join(packageRoot,'build'))).filter(f=>f.endsWith('.mjs')&&!f.endsWith('.test.mjs')).sort();
17
+ const hash=createHash('sha256');
18
+ for(const f of files)hash.update(f).update(await readFile(path.join(packageRoot,'build',f)));
19
+ hash.update(await readFile(path.join(packageRoot,'package.json')));
20
+ for(const name of ['@babel/core','@babel/parser','@babel/traverse','@babel/generator','@babel/types','babel-plugin-react-compiler','esbuild'])hash.update(name+':'+require(name+'/package.json').version);
21
+ // CSS graph outputs also depend on the installed style engine and browser
22
+ // database, including nested/non-hoisted installations with different pins.
23
+ for(const name of ['lightningcss','browserslist','caniuse-lite','electron-to-chromium','postcss','postcss-value-parser','postcss-flexbugs-fixes','postcss-preset-env']){
24
+ let filename;
25
+ try{filename=require.resolve(name+'/package.json');}
26
+ catch{
27
+ let directory=path.dirname(require.resolve(name));
28
+ while(true){
29
+ try{const candidate=path.join(directory,'package.json'),value=JSON.parse(await readFile(candidate,'utf8'));if(value.name===name){filename=candidate;break;}}catch{}
30
+ const parent=path.dirname(directory);if(parent===directory)throw new Error(`Cannot fingerprint ${name}`);directory=parent;
31
+ }
32
+ }
33
+ hash.update(name).update(await readFile(filename));
34
+ }
35
+ return hash.digest('hex');
36
+ })();
37
+ return frameworkIdentity;
38
+ }
39
+ export async function buildCacheIdentity(root){
40
+ const hash=createHash('sha256').update(await frameworkHash()).update(process.version).update(await realpath(root));
41
+ 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']){
42
+ hash.update(name);
43
+ try{const f=await open(path.join(root,name));try{if((await f.stat()).size>16*1024*1024)return null;hash.update(await f.readFile());}finally{await f.close();}}catch(e){if(e.code!=='ENOENT')return null;hash.update('missing');}
44
+ }
45
+ // Never write environment values, credentials or encryption keys into the
46
+ // cache identity. Environment-sensitive pure transforms still invalidate.
47
+ hash.update(cacheHash(JSON.stringify(Object.entries(process.env).sort())));
48
+ return hash.digest('hex');
49
+ }
50
+ export async function privateCacheDirectory(root,kind){
51
+ if(process.env.PRNEXT_BUILD_CACHE==='0'||!/^[a-z0-9-]+$/.test(kind))return null;
52
+ try{
53
+ let directory=await realpath(root);
54
+ for(const part of ['.prnext-cache',kind]){
55
+ directory=path.join(directory,part);
56
+ try{await mkdir(directory,{mode:0o700});}catch(e){if(e.code!=='EEXIST')throw e;}
57
+ const info=await lstat(directory);if(!info.isDirectory()||info.isSymbolicLink())return null;
58
+ }
59
+ return directory;
60
+ }catch{return null;}
61
+ }
62
+ /** Best-effort V8 cache retention; never follow links or traverse app sources. */
63
+ export async function pruneCompilerCache(directory,{maxBytes=64*1024*1024,maxFiles=2048}={}){
64
+ if(!directory)return;
65
+ try{
66
+ const files=[];let scanned=0;
67
+ async function collect(folder,depth){
68
+ if(depth>3||scanned>=8192)return;
69
+ for(const entry of await readdir(folder,{withFileTypes:true})){
70
+ if(++scanned>8192)break;
71
+ const name=path.join(folder,entry.name),info=await lstat(name).catch(()=>null);
72
+ if(!info||info.isSymbolicLink())continue;
73
+ if(info.isDirectory())await collect(name,depth+1);
74
+ else if(info.isFile())files.push({name,size:info.size,time:info.mtimeMs});
75
+ }
76
+ }
77
+ const info=await lstat(directory);if(!info.isDirectory()||info.isSymbolicLink())return;
78
+ await collect(directory,0);files.sort((a,b)=>b.time-a.time);let bytes=0;
79
+ for(let i=0;i<files.length;i++){bytes+=files[i].size;if(i>=maxFiles||bytes>maxBytes)await unlink(files[i].name).catch(()=>{});}
80
+ }catch{/* Optional caches must never prevent compilation. */}
81
+ }
82
+ export async function readCacheRecord(directory,key,limit=16*1024*1024){
83
+ if(!directory||!/^[a-f0-9]{64}$/.test(key))return null;
84
+ let file;
85
+ try{
86
+ const name=path.join(directory,key+'.json'),info=await lstat(name);
87
+ if(!info.isFile()||info.isSymbolicLink()||info.size>limit)return null;
88
+ file=await open(name,fsConstants.O_RDONLY|(fsConstants.O_NOFOLLOW||0));
89
+ const stat=await file.stat();if(!stat.isFile()||stat.size>limit)return null;
90
+ const record=JSON.parse(await file.readFile('utf8'));
91
+ if(record.version!==1||record.key!==key||typeof record.payload!=='string'||cacheHash(record.payload)!==record.checksum)return null;
92
+ return JSON.parse(record.payload);
93
+ }catch{return null;}finally{await file?.close().catch(()=>{});}
94
+ }
95
+ export async function writeCacheRecord(directory,key,value,limit=16*1024*1024,{maxFiles=64}={}){
96
+ if(!directory||!/^[a-f0-9]{64}$/.test(key))return false;
97
+ if(!Number.isInteger(maxFiles)||maxFiles<1||maxFiles>4096)return false;
98
+ const filename=path.join(directory,key+'.json'),temporary=filename+'.'+randomUUID()+'.tmp';
99
+ try{
100
+ const payload=JSON.stringify(value),body=JSON.stringify({version:1,key,checksum:cacheHash(payload),payload});
101
+ if(Buffer.byteLength(body)>limit)return false;
102
+ const file=await open(temporary,'wx',0o600);try{await file.writeFile(body);}finally{await file.close();}
103
+ await rename(temporary,filename);
104
+ const records=[];
105
+ for(const name of await readdir(directory))if(/^[a-f0-9]{64}\.json$/.test(name)){
106
+ const stat=await lstat(path.join(directory,name)).catch(()=>null);if(stat?.isFile()&&!stat.isSymbolicLink())records.push({name,size:stat.size,time:stat.mtimeMs});
107
+ }
108
+ records.sort((a,b)=>b.time-a.time);let total=0;
109
+ for(let i=0;i<records.length;i++){total+=records[i].size;if(i>=maxFiles||total>MAX_DISK)await unlink(path.join(directory,records[i].name)).catch(()=>{});}
110
+ return true;
111
+ }catch{return false;}finally{await unlink(temporary).catch(()=>{});}
112
+ }
@@ -1,9 +1,9 @@
1
1
  import { rename } from '../runtime/fs.mjs';
2
2
  import { createReadStream, createWriteStream } from 'node:fs';
3
- import { readdir, rm, stat, utimes, mkdir, copyFile } from 'node:fs/promises';
3
+ import { readdir, rm, stat,lstat, utimes, mkdir, copyFile } from 'node:fs/promises';
4
4
  import path from 'node:path';
5
5
  import { pipeline } from 'node:stream/promises';
6
- import { createGzip, createGunzip } from 'node:zlib';
6
+ import { createGzip, createGunzip,createBrotliCompress,createBrotliDecompress,constants } from 'node:zlib';
7
7
  import { createHash, randomUUID } from 'node:crypto';
8
8
 
9
9
  async function digest(stream, limit = Infinity) {
@@ -16,15 +16,47 @@ async function digest(stream, limit = Infinity) {
16
16
  }
17
17
  return hash.digest('hex');
18
18
  }
19
- async function trimCache(directory) {
20
- const entries = (await Promise.all((await readdir(directory)).filter(name => /^[a-f0-9]{64}\.gz$/.test(name)).map(async name => {
21
- try { return {name, ...await stat(path.join(directory,name))}; } catch(error) { if(error.code==='ENOENT')return null; throw error; }
22
- }))).filter(Boolean).sort((a,b)=>b.mtimeMs-a.mtimeMs);
23
- let bytes = 0;
24
- for (let index=0;index<entries.length;index++) {
25
- bytes += entries[index].size;
26
- if(index>=512 || bytes>64*1024*1024)await rm(path.join(directory,entries[index].name),{force:true});
27
- }
19
+ // Scan once per build, not after every compressed file. Two compressors share
20
+ // this bounded index and serialize publication/eviction, including overwrites.
21
+ // Cached bytes are still decoded and hashed before reuse below.
22
+ async function compressionCache(directory) {
23
+ if (!directory || process.env.PRNEXT_BUILD_CACHE === '0') return;
24
+ try {
25
+ await mkdir(directory,{recursive:true});
26
+ const info=await lstat(directory);
27
+ if(!info.isDirectory()||info.isSymbolicLink())return;
28
+ const entries=[];
29
+ for(const name of await readdir(directory)) {
30
+ if(!/^[a-f0-9]{64}\.(?:gz|br)$/.test(name))continue;
31
+ const info=await lstat(path.join(directory,name)).catch(()=>null);
32
+ if(info?.isFile()&&!info.isSymbolicLink())entries.push({name,size:info.size,time:info.mtimeMs});
33
+ }
34
+ entries.sort((a,b)=>a.time-b.time);
35
+ const index=new Map(entries.map(entry=>[entry.name,entry.size]));
36
+ let bytes=entries.reduce((sum,entry)=>sum+entry.size,0),pending=Promise.resolve(),disabled=false;
37
+ async function trim() {
38
+ while(index.size>512||bytes>64*1024*1024) {
39
+ const [name,size]=index.entries().next().value;
40
+ await rm(path.join(directory,name),{force:true});index.delete(name);bytes-=size;
41
+ }
42
+ }
43
+ await trim();
44
+ return {directory,write(name,source){
45
+ const task=pending.then(async()=>{
46
+ if(disabled)return;
47
+ let temporary;
48
+ try {
49
+ const {size}=await stat(source);if(size>4*1024*1024)return;
50
+ const filename=path.join(directory,name);temporary=filename+'.'+randomUUID()+'.tmp';
51
+ await copyFile(source,temporary);await rename(temporary,filename);
52
+ bytes-=index.get(name)||0;index.delete(name);index.set(name,size);bytes+=size;
53
+ await trim();
54
+ }catch{disabled=true;/* Optional cache failures never invalidate a correct sidecar. */}
55
+ finally{if(temporary)await rm(temporary,{force:true}).catch(()=>{});}
56
+ });
57
+ pending=task.catch(()=>{});return task;
58
+ }};
59
+ }catch{/* Unavailable/corrupt cache metadata is an optimization miss. */}
28
60
  }
29
61
 
30
62
  // Images, archives and WOFF fonts already contain compressed data. Only build
@@ -45,15 +77,16 @@ async function* files(directory, additional) {
45
77
  }
46
78
  }
47
79
 
48
- async function compress(filename, cacheDirectory) {
49
- const original = await stat(filename);
80
+ async function compress(filename, cache,format,original,key) {
50
81
  if (!original.size) return 'skipped';
51
- const sidecar = filename + '.gz';
82
+ const sidecar = filename + '.'+format;
83
+ const decode=()=>format==='br'?createBrotliDecompress():createGunzip();
52
84
  // Static builders may already supply gzip files. Validate them rather than
53
85
  // overwriting public input or publishing a mismatched encoded response.
54
- const existing = await stat(sidecar).catch(error => { if (error.code !== 'ENOENT') throw error; });
86
+ const existing = await lstat(sidecar).catch(error => { if (error.code !== 'ENOENT') throw error; });
55
87
  if (existing) {
56
- const input = createReadStream(sidecar), unzip = createGunzip();
88
+ if(!existing.isFile()||existing.isSymbolicLink())throw new Error('Precompressed assets must be regular files: '+path.basename(sidecar));
89
+ const input = createReadStream(sidecar), unzip = decode();
57
90
  const verified = digest(unzip, original.size);
58
91
  const pumping = pipeline(input, unzip);
59
92
  const [actual, expected] = await Promise.all([verified, digest(createReadStream(filename)), pumping]);
@@ -62,24 +95,24 @@ async function compress(filename, cacheDirectory) {
62
95
  await utimes(sidecar, original.atimeMs / 1000, original.mtimeMs / 1000);
63
96
  return 'reused';
64
97
  }
65
- const key = cacheDirectory && original.size <= 64*1024*1024 ? await digest(createReadStream(filename)) : null;
66
- const cached = key && path.join(cacheDirectory,key+'.gz');
98
+ const cached = key && cache && path.join(cache.directory,key+'.'+format);
67
99
  let reused = false;
68
100
  try {
69
101
  if (cached) {
70
102
  try {
71
- if ((await stat(cached)).size <= 4*1024*1024) {
103
+ const info=await lstat(cached);
104
+ if (info.isFile()&&!info.isSymbolicLink()&&info.size <= 4*1024*1024) {
72
105
  // Validate cached content, including gzip CRC, using bounded buffers.
73
106
  // A corrupt cache is a miss, never a corrupt published response.
74
- const input = createReadStream(cached), unzip = createGunzip();
107
+ const input = createReadStream(cached), unzip = decode();
75
108
  const verified = digest(unzip, original.size);
76
109
  const pumping = pipeline(input,unzip);
77
110
  const [actual] = await Promise.all([verified,pumping]);
78
111
  if (actual === key) { await copyFile(cached,sidecar); reused = true; }
79
112
  }
80
- } catch (error) { if (!['ENOENT','ENOTDIR','Z_DATA_ERROR','Z_BUF_ERROR','CACHE_SIZE'].includes(error.code)) throw error; }
113
+ } catch { /* Brotli has several decoder-specific error codes. Every invalid cache entry is a miss. */ }
81
114
  }
82
- if (!reused) await pipeline(createReadStream(filename), createGzip(), createWriteStream(sidecar, { flags: 'wx' }));
115
+ if (!reused) await pipeline(createReadStream(filename),format==='br'?createBrotliCompress({params:{[constants.BROTLI_PARAM_QUALITY]:5,[constants.BROTLI_PARAM_SIZE_HINT]:Math.min(original.size,0xffffffff)}}):createGzip(), createWriteStream(sidecar, { flags: 'wx' }));
83
116
  if ((await stat(sidecar)).size >= original.size) {
84
117
  await rm(sidecar);
85
118
  return 'skipped';
@@ -90,13 +123,7 @@ async function compress(filename, cacheDirectory) {
90
123
  const modified = original.mtimeMs / 1000;
91
124
  await utimes(filename, accessed, modified);
92
125
  await utimes(sidecar, accessed, modified);
93
- if (cached && !reused && (await stat(sidecar)).size <= 4*1024*1024) {
94
- await mkdir(cacheDirectory,{recursive:true});
95
- const temporary=cached+'.'+randomUUID()+'.tmp';
96
- try { await copyFile(sidecar,temporary); await rename(temporary,cached); }
97
- finally { await rm(temporary,{force:true}); }
98
- await trimCache(cacheDirectory);
99
- }
126
+ if (cached && !reused) await cache.write(key+'.'+format,sidecar);
100
127
  return reused ? 'reused' : 'compressed';
101
128
  } catch (error) {
102
129
  await rm(sidecar, { force: true });
@@ -107,6 +134,7 @@ async function compress(filename, cacheDirectory) {
107
134
  /** Precompress unpublished build assets with bounded buffers and two workers. */
108
135
  export async function precompressBuild(stage, manifest, {cacheDirectory} = {}) {
109
136
  const stats = {compressed:0,reused:0,skipped:0};
137
+ const cache=await compressionCache(cacheDirectory);
110
138
  // Route Handler bodies are opaque bytes. Only consider published, unencoded
111
139
  // response artifacts; the size check also rejects incompressible binary data.
112
140
  const additional = new Set((manifest?.prerendered || []).filter(record =>
@@ -124,7 +152,11 @@ export async function precompressBuild(stage, manifest, {cacheDirectory} = {}) {
124
152
  try {
125
153
  const next = await iterator.next();
126
154
  if (next.done) return;
127
- stats[await compress(next.value, cacheDirectory)]++;
155
+ const original=await stat(next.value);
156
+ const key=cache&&original.size<=64*1024*1024?await digest(createReadStream(next.value)):null;
157
+ const gzip=await compress(next.value,cache,'gz',original,key);
158
+ const brotli=await compress(next.value,cache,'br',original,key);
159
+ stats[gzip==='compressed'||brotli==='compressed'?'compressed':gzip==='reused'||brotli==='reused'?'reused':'skipped']++;
128
160
  } catch (error) { failure ||= error; }
129
161
  }
130
162
  };