@thomas.f/prnext 0.1.17-alpha → 0.1.19-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -868
- package/build/actions.mjs +27 -15
- package/build/analyze.mjs +180 -24
- package/build/app-config.mjs +13 -0
- package/build/app-static.mjs +34 -12
- package/build/app-styles.mjs +79 -0
- package/build/app.mjs +122 -46
- package/build/bundle-runtime.mjs +22 -9
- package/build/cache-components.mjs +1 -1
- package/build/cache-handlers.mjs +1 -1
- package/build/compact-css.mjs +53 -0
- package/build/compilation-cache.mjs +269 -0
- package/build/compilation-store.mjs +142 -0
- package/build/compiler.mjs +34 -10
- package/build/config.mjs +34 -3
- package/build/create.mjs +239 -0
- package/build/css-modules.mjs +56 -12
- package/build/deployment-assets.mjs +89 -0
- package/build/deployment-config.mjs +18 -0
- package/build/dev-css-assets.mjs +173 -0
- package/build/dev-css-update.mjs +18 -14
- package/build/dev-dependencies.mjs +1 -1
- package/build/dev-discovery.mjs +3 -3
- package/build/dev-lazy-build.mjs +77 -0
- package/build/dev.mjs +15 -8
- package/build/dynamic.mjs +14 -7
- package/build/edge.mjs +3 -3
- package/build/fonts.mjs +1 -1
- package/build/framework-imports.mjs +15 -5
- package/build/html-only-config.mjs +53 -0
- package/build/html-only-graph.mjs +67 -0
- package/build/incremental.mjs +112 -18
- package/build/index.mjs +90 -28
- package/build/instrumentation.mjs +30 -0
- package/build/lazy-dynamic-imports.mjs +137 -0
- package/build/metadata-routes.mjs +1 -1
- 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-type-config.mjs +37 -0
- package/build/npm-packages.mjs +1 -0
- package/build/persistent-cache.mjs +18 -3
- package/build/precompress.mjs +1 -1
- package/build/prerender-cache.mjs +70 -0
- package/build/prerender-config.mjs +29 -0
- package/build/prerender-pages.mjs +19 -9
- package/build/prerender-pool.mjs +165 -0
- package/build/prerender-process-worker.mjs +58 -0
- package/build/prerender-worker.mjs +5 -5
- package/build/production-builder.mjs +59 -10
- package/build/react-compiler-report.mjs +225 -0
- package/build/react-compiler.mjs +139 -0
- package/build/runtime-contract.mjs +2 -2
- package/build/standalone.mjs +21 -3
- package/build/static-export.mjs +4 -4
- package/build/styles.mjs +6 -4
- package/build/transform.mjs +1 -1
- package/build/typed-routes.mjs +4 -0
- package/build/typescript-dev.mjs +35 -8
- package/build/typescript-native-worker.mjs +55 -0
- package/build/typescript-native.mjs +18 -0
- package/build/typescript-toolchain.mjs +19 -0
- package/build/typescript.mjs +6 -4
- package/build/webpack-graph.mjs +19 -6
- package/build/worker-memory.mjs +9 -0
- package/cli.mjs +70 -5
- package/compat/activity-cache.cjs +26 -0
- package/compat/after.cjs +6 -4
- package/compat/app-dynamic.cjs +12 -1
- package/compat/app-layout-context.cjs +52 -2
- package/compat/base64.cjs +10 -0
- package/compat/cache-handlers.cjs +15 -3
- package/compat/cache-wire.cjs +28 -0
- package/compat/cache.cjs +2 -1
- package/compat/cache.d.cts +4 -0
- package/compat/cookies.cjs +21 -5
- package/compat/data-cache.cjs +12 -8
- package/compat/flight-delta.cjs +152 -0
- package/compat/headers.cjs +4 -2
- package/compat/incremental-cache.cjs +20 -7
- package/compat/index.d.ts +69 -4
- package/compat/link.cjs +13 -10
- package/compat/navigation-authorization.cjs +9 -0
- package/compat/navigation-stages.cjs +19 -0
- package/compat/offline-store.cjs +11 -0
- package/compat/offline.cjs +5 -0
- package/compat/offline.d.cts +2 -0
- package/compat/redis-cache.cjs +201 -0
- package/compat/redis-cache.d.cts +37 -0
- package/compat/server-primitives.cjs +31 -11
- package/compat/server.d.cts +15 -2
- package/compat/static-generation.cjs +7 -0
- package/compat/use-cache.cjs +3 -0
- package/docs/ai/compatibility.md +248 -2
- package/docs/ai/data.md +165 -5
- package/docs/ai/deployment.md +158 -0
- package/docs/ai/quickstart.md +29 -4
- package/docs/ai/routing.md +93 -0
- package/docs/ai/troubleshooting.md +116 -2
- package/package.json +40 -9
- package/runtime/action-client.mjs +10 -4
- package/runtime/action-server.mjs +2 -0
- package/runtime/app-client.mjs +316 -92
- package/runtime/app-content.mjs +4 -3
- package/runtime/app-partial-client.mjs +3 -0
- package/runtime/app-partial.mjs +1 -1
- package/runtime/app-prefetch-constants.mjs +4 -0
- package/runtime/app-prefetch.mjs +2 -3
- package/runtime/app-render-key.mjs +5 -0
- package/runtime/app-render.mjs +88 -28
- package/runtime/app-routing.mjs +22 -9
- package/runtime/app-static.mjs +18 -8
- package/runtime/app-stream-html.mjs +10 -53
- package/runtime/app-style-selection.mjs +34 -0
- package/runtime/app-styles.mjs +154 -0
- package/runtime/atomic-file.mjs +27 -0
- package/runtime/cache-components-rsc.mjs +7 -2
- package/runtime/cache-request.mjs +2 -1
- package/runtime/deployment-client.mjs +19 -0
- package/runtime/dev-client.mjs +13 -3
- package/runtime/dev-compiler-broker.mjs +42 -0
- package/runtime/dev-file-changes.mjs +123 -8
- package/runtime/dev-indicator.mjs +8 -3
- package/runtime/dev-lazy-imports.mjs +121 -0
- package/runtime/dev-lazy-staging.mjs +14 -0
- package/runtime/dev-lazy-tool.mjs +16 -0
- package/runtime/dev-style-retention.mjs +15 -0
- package/runtime/development.mjs +83 -25
- package/runtime/document.mjs +1 -1
- package/runtime/edge.mjs +6 -5
- package/runtime/fetch-cache.mjs +10 -8
- package/runtime/flight-delta.mjs +74 -0
- package/runtime/flight-tap.mjs +28 -0
- package/runtime/fs.mjs +15 -8
- package/runtime/html-only.mjs +27 -0
- package/runtime/http.mjs +2 -0
- package/runtime/incremental-cache.mjs +25 -4
- package/runtime/instrumentation.mjs +49 -0
- package/runtime/lazy-import-client.mjs +38 -0
- package/runtime/navigation-recovery.mjs +36 -0
- package/runtime/navigation.mjs +7 -0
- package/runtime/node-html-stream.mjs +47 -0
- package/runtime/pages-client.mjs +14 -7
- package/runtime/pages-navigation.mjs +48 -63
- package/runtime/prefetch-scheduler.mjs +110 -0
- package/runtime/profile.mjs +14 -0
- package/runtime/profiles.json +2 -2
- package/runtime/render.mjs +3 -0
- package/runtime/route-pattern.mjs +44 -0
- package/runtime/rsc-worker.mjs +34 -12
- package/runtime/trace.mjs +78 -0
- package/runtime/watch-project.mjs +19 -15
- package/runtime/worker.mjs +70 -26
- package/vendor/nft/LICENSE +7 -0
- package/vendor/nft/README.md +16 -0
- package/vendor/nft/out/analyze.d.ts +8 -0
- package/vendor/nft/out/analyze.js +1216 -0
- package/vendor/nft/out/analyze.js.map +1 -0
- package/vendor/nft/out/cli.d.ts +2 -0
- package/vendor/nft/out/cli.js +94 -0
- package/vendor/nft/out/cli.js.map +1 -0
- package/vendor/nft/out/fs.d.ts +22 -0
- package/vendor/nft/out/fs.js +106 -0
- package/vendor/nft/out/fs.js.map +1 -0
- package/vendor/nft/out/index.d.ts +4 -0
- package/vendor/nft/out/index.js +25 -0
- package/vendor/nft/out/index.js.map +1 -0
- package/vendor/nft/out/node-file-trace.d.ts +41 -0
- package/vendor/nft/out/node-file-trace.js +380 -0
- package/vendor/nft/out/node-file-trace.js.map +1 -0
- package/vendor/nft/out/resolve-dependency.d.ts +6 -0
- package/vendor/nft/out/resolve-dependency.js +427 -0
- package/vendor/nft/out/resolve-dependency.js.map +1 -0
- package/vendor/nft/out/types.d.ts +66 -0
- package/vendor/nft/out/types.js +2 -0
- package/vendor/nft/out/types.js.map +1 -0
- package/vendor/nft/out/utils/ast-helpers.js +49 -0
- package/vendor/nft/out/utils/ast-helpers.js.map +1 -0
- package/vendor/nft/out/utils/binary-locators.js +88 -0
- package/vendor/nft/out/utils/binary-locators.js.map +1 -0
- package/vendor/nft/out/utils/get-package-base.js +30 -0
- package/vendor/nft/out/utils/get-package-base.js.map +1 -0
- package/vendor/nft/out/utils/interop-require.js +24 -0
- package/vendor/nft/out/utils/interop-require.js.map +1 -0
- package/vendor/nft/out/utils/node-version.js +10 -0
- package/vendor/nft/out/utils/node-version.js.map +1 -0
- package/vendor/nft/out/utils/sharedlib-emit.js +33 -0
- package/vendor/nft/out/utils/sharedlib-emit.js.map +1 -0
- package/vendor/nft/out/utils/special-cases.js +347 -0
- package/vendor/nft/out/utils/special-cases.js.map +1 -0
- package/vendor/nft/out/utils/static-eval.js +579 -0
- package/vendor/nft/out/utils/static-eval.js.map +1 -0
- package/vendor/nft/out/utils/types.js +2 -0
- package/vendor/nft/out/utils/types.js.map +1 -0
- package/vendor/nft/out/utils/wrappers.js +659 -0
- package/vendor/nft/out/utils/wrappers.js.map +1 -0
- package/vendor/nft/package.json +4 -0
- package/vendor/nft/upstream.json +51 -0
package/build/create.mjs
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import { constants } from 'node:fs';
|
|
3
|
+
import { lstat, mkdir, open, readFile, readdir, realpath, unlink } from 'node:fs/promises';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import { packageManifest } from '../native/resolve.mjs';
|
|
6
|
+
import { supportedNode } from '../native/diagnostics.mjs';
|
|
7
|
+
import { npmCommand } from '../native/npm.mjs';
|
|
8
|
+
|
|
9
|
+
export const createHelp = `Usage: prnext create <directory> [--js] [--no-install] [--json]
|
|
10
|
+
|
|
11
|
+
Create a minimal App Router project and install its dependencies with npm.
|
|
12
|
+
TypeScript is the default; --js creates a JavaScript project.
|
|
13
|
+
The directory must be absent or completely empty. Existing files are never replaced.
|
|
14
|
+
--no-install prepares files without running npm or accessing the network.
|
|
15
|
+
Inside an existing workspace, use --no-install and its own package manager.
|
|
16
|
+
|
|
17
|
+
npx @thomas.f/prnext create my-app
|
|
18
|
+
cd my-app
|
|
19
|
+
npm run dev
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
export function parseCreateArgs(args, cwd = process.cwd()) {
|
|
23
|
+
const flags = new Set(), positional = [];
|
|
24
|
+
let literal = false;
|
|
25
|
+
for (const arg of args) {
|
|
26
|
+
if (literal) positional.push(arg);
|
|
27
|
+
else if (arg === '--') literal = true;
|
|
28
|
+
else if (['--no-install', '--js', '--json'].includes(arg)) {
|
|
29
|
+
if (flags.has(arg)) throw new Error(`Duplicate create option: ${arg}`);
|
|
30
|
+
flags.add(arg);
|
|
31
|
+
} else if (arg.startsWith('-')) throw new Error(`Unknown create option: ${arg}`);
|
|
32
|
+
else positional.push(arg);
|
|
33
|
+
}
|
|
34
|
+
if (positional.length !== 1 || !positional[0].trim() || /[\0\r\n]/.test(positional[0])) throw new Error('Provide one project directory: prnext create my-app');
|
|
35
|
+
return { directory: path.resolve(cwd, positional[0]), options: { install: !flags.has('--no-install'), typescript: !flags.has('--js'), json: flags.has('--json') } };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const samePath = (a, b) => process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
|
|
39
|
+
const sameFile = (a, b) => a.dev === b.dev && a.ino === b.ino;
|
|
40
|
+
async function info(file, options) {
|
|
41
|
+
try { return await lstat(file, options); } catch (error) { if (error.code === 'ENOENT') return null; throw error; }
|
|
42
|
+
}
|
|
43
|
+
async function physicalDirectory(directory, missing = false) {
|
|
44
|
+
// macOS exposes standard directories through root-owned /private aliases.
|
|
45
|
+
// Resolve only those exact system targets; every user-controlled descendant
|
|
46
|
+
// still goes through the strict no-symlink walk below, on every write.
|
|
47
|
+
if (process.platform === 'darwin') {
|
|
48
|
+
const alias = /^\/(?:var|tmp|etc)(?=\/|$)/.exec(directory)?.[0];
|
|
49
|
+
if (alias && (await info(alias))?.isSymbolicLink() && await realpath(alias) === `/private${alias}`) {
|
|
50
|
+
directory = `/private${directory}`;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
const root = path.parse(directory).root;
|
|
54
|
+
const inspected = [];
|
|
55
|
+
let current = root;
|
|
56
|
+
for (const part of path.relative(root, directory).split(path.sep).filter(Boolean)) {
|
|
57
|
+
if (process.platform === 'win32' && (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part) || /[<>:"|?*]|[. ]$/.test(part))) throw new Error(`Invalid Windows directory name: ${part}`);
|
|
58
|
+
current = path.join(current, part);
|
|
59
|
+
const entry = await info(current, { bigint: true });
|
|
60
|
+
if (!entry && missing) continue;
|
|
61
|
+
if (!entry?.isDirectory() || entry.isSymbolicLink()) throw new Error(`Project directories must not be files, symbolic links or junctions: ${current}`);
|
|
62
|
+
inspected.push([current, entry]);
|
|
63
|
+
}
|
|
64
|
+
if (!missing) {
|
|
65
|
+
const canonical = await realpath(directory);
|
|
66
|
+
if (!samePath(canonical, directory)) {
|
|
67
|
+
if (process.platform !== 'win32') throw new Error('Use a physical project directory without symbolic links or junctions.');
|
|
68
|
+
// DOS 8.3 names are directory aliases, not reparse points. Accept the
|
|
69
|
+
// expanded name only after rechecking every observed directory identity
|
|
70
|
+
// and proving that the canonical target is the same physical directory.
|
|
71
|
+
for (const [file, original] of inspected) {
|
|
72
|
+
const observed = await info(file, { bigint: true });
|
|
73
|
+
if (!observed?.isDirectory() || observed.isSymbolicLink() || !sameFile(original, observed)) throw new Error(`Project directory changed during creation: ${file}`);
|
|
74
|
+
}
|
|
75
|
+
const original = inspected.at(-1)?.[1] || await info(directory, { bigint: true });
|
|
76
|
+
const resolved = await info(canonical, { bigint: true });
|
|
77
|
+
if (!original?.isDirectory() || original.isSymbolicLink() || original.ino === 0n || !resolved?.isDirectory() || resolved.isSymbolicLink() || !sameFile(original, resolved)) throw new Error('Use a physical project directory without symbolic links or junctions.');
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function parentWorkspace(directory) {
|
|
83
|
+
for (let current = path.dirname(directory); ; current = path.dirname(current)) {
|
|
84
|
+
const workspace = await info(path.join(current, 'pnpm-workspace.yaml'));
|
|
85
|
+
if (workspace) return current;
|
|
86
|
+
const file = path.join(current, 'package.json'), entry = await info(file);
|
|
87
|
+
if (entry) {
|
|
88
|
+
if (!entry.isFile() || entry.isSymbolicLink() || entry.size > 1024 * 1024) throw new Error(`Cannot safely inspect parent package.json: ${file}`);
|
|
89
|
+
let manifest;
|
|
90
|
+
try { manifest = JSON.parse((await readFile(file, 'utf8')).replace(/^\uFEFF/, '')); }
|
|
91
|
+
catch { throw new Error(`Parent package.json is invalid: ${file}`); }
|
|
92
|
+
if (manifest.workspaces) return current;
|
|
93
|
+
}
|
|
94
|
+
if (current === path.dirname(current)) return null;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function projectName(directory) {
|
|
99
|
+
const name = path.basename(directory).toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[._-]+|[._-]+$/g, '').slice(0, 100);
|
|
100
|
+
return !name || ['node_modules', 'favicon.ico'].includes(name) ? 'prnext-app' : name;
|
|
101
|
+
}
|
|
102
|
+
const json = value => JSON.stringify(value, null, 2) + '\n';
|
|
103
|
+
|
|
104
|
+
function projectFiles(name, typescript) {
|
|
105
|
+
const extension = typescript ? 'tsx' : 'jsx';
|
|
106
|
+
const scripts = { dev: 'prnext dev', build: 'prnext build', start: 'prnext start', check: 'prnext check', ...(typescript ? { typecheck: 'prnext typecheck' } : {}) };
|
|
107
|
+
const manifest = { name, version: '0.0.0', private: true, type: 'module', scripts,
|
|
108
|
+
dependencies: { [packageManifest.name]: packageManifest.version, ...Object.fromEntries(Object.entries(packageManifest.peerDependencies).filter(([name])=>!packageManifest.peerDependenciesMeta?.[name]?.optional)) },
|
|
109
|
+
...(typescript ? { devDependencies: { typescript: '^5.9.0', '@types/node': '^22.0.0', '@types/react': '^19.0.0', '@types/react-dom': '^19.0.0' } } : {}),
|
|
110
|
+
engines: { node: packageManifest.engines.node } };
|
|
111
|
+
const files = new Map([
|
|
112
|
+
['package.json', json(manifest)],
|
|
113
|
+
[`app/layout.${extension}`, `import './globals.css';\n${typescript ? "import type { ReactNode } from 'react';\n" : ''}
|
|
114
|
+
export const metadata = { title: 'My PRNext app', description: 'A React application built with PRNext.' };
|
|
115
|
+
|
|
116
|
+
export default function RootLayout({ children }${typescript ? ': { children: ReactNode }' : ''}) {
|
|
117
|
+
return <html lang="en"><body>{children}</body></html>;
|
|
118
|
+
}
|
|
119
|
+
`],
|
|
120
|
+
[`app/page.${extension}`, `export default function Home() {
|
|
121
|
+
return <main><h1>PRNext</h1><p>Edit <code>app/page.${extension}</code> to get started.</p></main>;
|
|
122
|
+
}
|
|
123
|
+
`],
|
|
124
|
+
['app/globals.css', `:root { color-scheme: dark; font-family: system-ui, sans-serif; background: #0a0a0a; color: #ededed; }
|
|
125
|
+
* { box-sizing: border-box; }
|
|
126
|
+
body { margin: 0; }
|
|
127
|
+
main { max-width: 64rem; margin: auto; padding: clamp(2rem, 8vw, 6rem) 1.5rem; }
|
|
128
|
+
h1 { font-size: clamp(2.5rem, 8vw, 4rem); letter-spacing: -.04em; }
|
|
129
|
+
p { line-height: 1.6; }
|
|
130
|
+
code { font-size: .95em; }
|
|
131
|
+
`],
|
|
132
|
+
['.gitignore', 'node_modules/\n.prnext/\n.prnext-types/\n.prnext-dev/\n.prnext-cache/\n.env*\n!.env.example\n*.tsbuildinfo\n'],
|
|
133
|
+
['README.md', `# ${name}\n\nA minimal PRNext application.\n\nRun \`npm install\` if dependencies have not been installed, then:\n\n\`\`\`sh\nnpm run dev\n\`\`\`\n\nEdit \`app/page.${extension}\`. Use the local URL printed by the server.\n\n\`\`\`sh\nnpm run check\n${typescript ? 'npm run typecheck\n' : ''}npm run build\nnpm start\n\`\`\`\n\nUse another port with \`npm run dev -- --port 3001\` when needed.\n\n[PRNext documentation](https://prnext.dev) · Installed AI guide: \`npx --no-install prnext docs --ai\`.\nPRNext is experimental; Next.js compatibility is partial.\n`],
|
|
134
|
+
['AGENTS.md', `# PRNext project\n\nThis application uses \`@thomas.f/prnext\`. Before changing routing, configuration, data fetching or deployment, read the installed guide at \`node_modules/@thomas.f/prnext/AI.md\` or run:\n\n\`\`\`sh\nnpx --no-install prnext docs --ai\nnpx --no-install prnext docs --ai --list\n\`\`\`\n\nRead only the relevant topic with \`npx --no-install prnext docs --ai <topic>\`. The installed documentation is version-specific; do not assume full Next.js compatibility. Preserve the project's package manager and lockfile.\n`],
|
|
135
|
+
]);
|
|
136
|
+
if (typescript) files.set('tsconfig.json', json({ compilerOptions: { target: 'ES2022', lib: ['DOM', 'DOM.Iterable', 'ES2022'], module: 'ESNext', moduleResolution: 'Bundler', jsx: 'preserve', strict: true, noEmit: true, esModuleInterop: true, skipLibCheck: true }, include: ['**/*.ts', '**/*.tsx'], exclude: ['node_modules', '.prnext', '.prnext-dev', '.prnext-cache'] }));
|
|
137
|
+
return files;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function abortError() { const error = new Error('Project creation canceled.'); error.name = 'AbortError'; return error; }
|
|
141
|
+
function aborted(signal) { if (signal?.aborted) throw abortError(); }
|
|
142
|
+
|
|
143
|
+
/** Run npm without a shell; a canceled command waits for its own installer tree. */
|
|
144
|
+
export async function installProject(directory, { signal, onOutput = () => {} } = {}) {
|
|
145
|
+
aborted(signal);
|
|
146
|
+
// npm resolves its working directory physically. Keep its environment on
|
|
147
|
+
// that same path so macOS aliases cannot split the lock tree.
|
|
148
|
+
directory = await realpath(directory);
|
|
149
|
+
aborted(signal);
|
|
150
|
+
const env = { ...process.env, INIT_CWD: directory, PWD: directory };
|
|
151
|
+
for (const key of Object.keys(env)) if (/^npm_config_(?:workspace|workspaces|global|prefix)$/i.test(key)) delete env[key];
|
|
152
|
+
const npm = npmCommand(env);
|
|
153
|
+
await new Promise((resolve, reject) => {
|
|
154
|
+
// The generated package.json already anchors npm's local project here.
|
|
155
|
+
// npm 10 on Windows treats an explicit --prefix as its global top and can
|
|
156
|
+
// add the project to its own dependencies even with --global=false.
|
|
157
|
+
const child = spawn(npm.command, [...npm.args, 'install', '--workspaces=false', '--global=false'], { cwd: directory, env, windowsHide: true, detached: process.platform !== 'win32', stdio: ['ignore', 'pipe', 'pipe'] });
|
|
158
|
+
let tail = '', timer, canceling = false;
|
|
159
|
+
const receive = stream => chunk => { tail = (tail + chunk).slice(-16000); onOutput(String(chunk), stream); };
|
|
160
|
+
child.stdout.on('data', receive('stdout'));
|
|
161
|
+
child.stderr.on('data', receive('stderr'));
|
|
162
|
+
function terminate(force) {
|
|
163
|
+
if (!child.pid) return;
|
|
164
|
+
if (process.platform === 'win32') {
|
|
165
|
+
if (child.exitCode !== null || child.signalCode) return;
|
|
166
|
+
const executable = path.join(env.SystemRoot || 'C:\\Windows', 'System32', 'taskkill.exe');
|
|
167
|
+
const killer = spawn(executable, ['/PID', String(child.pid), '/T', '/F'], { windowsHide: true, stdio: 'ignore' });
|
|
168
|
+
killer.on('error', () => child.kill());
|
|
169
|
+
} else {
|
|
170
|
+
try { process.kill(-child.pid, force ? 'SIGKILL' : 'SIGTERM'); } catch (error) { if (error.code !== 'ESRCH') child.kill(force ? 'SIGKILL' : 'SIGTERM'); }
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
function cancel() { canceling = true; terminate(false); timer ||= setTimeout(() => terminate(true), 5000); }
|
|
174
|
+
function cleanup() { clearTimeout(timer); signal?.removeEventListener('abort', cancel); }
|
|
175
|
+
child.once('error', error => { cleanup(); reject(error); });
|
|
176
|
+
child.once('close', (code, childSignal) => {
|
|
177
|
+
// A detached lifecycle script can close npm's pipes before it exits.
|
|
178
|
+
// Do not let npm's exit cancel escalation for that remaining group.
|
|
179
|
+
if ((canceling || signal?.aborted) && process.platform !== 'win32') terminate(true);
|
|
180
|
+
cleanup();
|
|
181
|
+
if (canceling || signal?.aborted) reject(abortError());
|
|
182
|
+
else if (code === 0) resolve();
|
|
183
|
+
else reject(new Error(`npm install failed (${childSignal || code}).${tail.trim() ? '\n' + tail.trim() : ''}`));
|
|
184
|
+
});
|
|
185
|
+
signal?.addEventListener('abort', cancel, { once: true });
|
|
186
|
+
if (signal?.aborted) cancel();
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
export async function createProject(directory, options = {}, services = {}) {
|
|
191
|
+
const root = path.resolve(directory), typescript = options.typescript !== false, install = options.install !== false;
|
|
192
|
+
aborted(options.signal);
|
|
193
|
+
const nodeVersion = services.nodeVersion || process.version;
|
|
194
|
+
if (!supportedNode(nodeVersion)) throw new Error(`Node ${nodeVersion} is unsupported. Use Node ${packageManifest.engines.node}.`);
|
|
195
|
+
await physicalDirectory(root, true);
|
|
196
|
+
const existing = await info(root);
|
|
197
|
+
if (existing && (await readdir(root)).length) throw new Error(`Project directory must be empty; no files were changed: ${root}`);
|
|
198
|
+
if (install && await parentWorkspace(root)) throw new Error('Automatic installation inside an existing workspace is not supported. Use --no-install, then add the project with the workspace package manager. No files were changed.');
|
|
199
|
+
aborted(options.signal);
|
|
200
|
+
await mkdir(root, { recursive: true });
|
|
201
|
+
await physicalDirectory(root);
|
|
202
|
+
const lockPath = path.join(root, '.prnext-create.lock');
|
|
203
|
+
const lock = await open(lockPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW || 0), 0o600);
|
|
204
|
+
const locked = await lock.stat();
|
|
205
|
+
try {
|
|
206
|
+
if ((await readdir(root)).some(name => name !== '.prnext-create.lock')) throw new Error(`Project directory is no longer empty; existing files were preserved: ${root}`);
|
|
207
|
+
const name = projectName(root), files = projectFiles(name, typescript);
|
|
208
|
+
for (const [relative, content] of files) {
|
|
209
|
+
aborted(options.signal);
|
|
210
|
+
await physicalDirectory(root);
|
|
211
|
+
const target = path.join(root, relative), parent = path.dirname(target);
|
|
212
|
+
await physicalDirectory(parent, true);
|
|
213
|
+
await mkdir(parent, { recursive: true });
|
|
214
|
+
await physicalDirectory(parent);
|
|
215
|
+
const file = await open(target, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | (constants.O_NOFOLLOW || 0), 0o644);
|
|
216
|
+
try { await file.writeFile(content, 'utf8'); } finally { await file.close(); }
|
|
217
|
+
}
|
|
218
|
+
const report = { ok: true, status: 'prepared', root, name, typescript, files: [...files.keys()], installed: false, next: install ? ['npm run dev'] : ['npm install', 'npm run dev'] };
|
|
219
|
+
if (install) {
|
|
220
|
+
try {
|
|
221
|
+
aborted(options.signal);
|
|
222
|
+
await (services.runInstall || installProject)(root, { signal: options.signal, onOutput: options.onOutput });
|
|
223
|
+
aborted(options.signal);
|
|
224
|
+
report.installed = true;
|
|
225
|
+
report.status = 'created';
|
|
226
|
+
} catch (error) {
|
|
227
|
+
report.ok = false;
|
|
228
|
+
report.status = error.name === 'AbortError' || options.signal?.aborted ? 'cancelled' : 'install-failed';
|
|
229
|
+
report.error = error.message;
|
|
230
|
+
report.next = ['npm install', 'npm run dev'];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
return report;
|
|
234
|
+
} finally {
|
|
235
|
+
await lock.close();
|
|
236
|
+
const current = await info(lockPath);
|
|
237
|
+
if (current?.isFile() && !current.isSymbolicLink() && sameFile(current, locked)) await unlink(lockPath);
|
|
238
|
+
}
|
|
239
|
+
}
|
package/build/css-modules.mjs
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
import { build as bundle } from 'esbuild';
|
|
2
|
-
import { mkdir, writeFile, realpath } from 'node:fs/promises';
|
|
2
|
+
import { mkdir, writeFile, realpath, readdir, readFile } from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import { createHash } from 'node:crypto';
|
|
5
5
|
import { runInNewContext } from 'node:vm';
|
|
6
6
|
import { createRequire } from 'node:module';
|
|
7
|
-
import { createStyleProcessor, stylesheetPattern, moduleStylesheetPattern } from './styles.mjs';
|
|
7
|
+
import { createStyleProcessor, resolveStandardBrowserTargets, stylesheetPattern, moduleStylesheetPattern } from './styles.mjs';
|
|
8
8
|
import {rename} from '../runtime/fs.mjs';
|
|
9
9
|
import {randomUUID} from 'node:crypto';
|
|
10
10
|
import {rm} from 'node:fs/promises';
|
|
11
|
+
import {cachedCompilation} from './compilation-cache.mjs';
|
|
12
|
+
import {compactCssModule,globalCssNames} from './compact-css.mjs';
|
|
11
13
|
|
|
12
14
|
const assetLoaders = Object.fromEntries(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.avif', '.svg', '.ico', '.woff', '.woff2', '.ttf', '.eot'].map(ext => [ext, 'file']));
|
|
13
15
|
|
|
@@ -23,13 +25,34 @@ async function resolveCss(esbuild, args) {
|
|
|
23
25
|
}
|
|
24
26
|
|
|
25
27
|
/** One cached CSS compilation supplies identical class maps to the server and the browser. */
|
|
26
|
-
export function createCssModules({ projectRoot, stage, assetBase = '/_prnext/assets', sassOptions, cssEngine }) {
|
|
27
|
-
const cache = new Map();
|
|
28
|
-
|
|
28
|
+
export function createCssModules({ projectRoot, stage, assetBase = '/_prnext/assets', sassOptions, cssEngine, dev = false, config = {} }) {
|
|
29
|
+
const cache = new Map(), compactNames = new Map();
|
|
30
|
+
let targets;
|
|
31
|
+
const browserTargets=()=>targets ??= Promise.resolve().then(()=>resolveStandardBrowserTargets(projectRoot));
|
|
32
|
+
const processor = createStyleProcessor({ projectRoot, sassOptions, cssEngine, browserTargets });
|
|
29
33
|
const hash = file => createHash('sha256').update(path.relative(projectRoot, file).replaceAll(path.sep, '/')).digest('hex').slice(0, 12);
|
|
34
|
+
let eligibility;
|
|
35
|
+
async function cacheEligible() {
|
|
36
|
+
if (dev || cssEngine==='postcss' || config.webpack || Object.keys(config.turbopack?.rules || {}).length || process.env.BROWSERSLIST_CONFIG || process.env.BROWSERSLIST_STATS || /\b(?:extends|stats)\b/i.test(process.env.BROWSERSLIST || '')) return false;
|
|
37
|
+
// A user PostCSS plugin can read arbitrary files, glob directories or use
|
|
38
|
+
// network/time without declaring dependencies. Its output is never cached.
|
|
39
|
+
const names = await readdir(projectRoot);
|
|
40
|
+
if (names.some(name => /^(?:postcss\.config|\.postcssrc)(?:\.|$)/.test(name))) return false;
|
|
41
|
+
try { if (JSON.parse(await readFile(path.join(projectRoot, 'package.json'), 'utf8')).postcss !== undefined) return false; }
|
|
42
|
+
catch (error) { if (error.code !== 'ENOENT') return false; }
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
const validMapping = value => {try{const parsed=JSON.parse(value);return parsed&&typeof parsed==='object'&&!Array.isArray(parsed)&&Object.values(parsed).every(value=>typeof value==='string');}catch{return false;}};
|
|
46
|
+
const validResult = value => value && typeof value.mapping === 'string' && value.mapping.length <= 16*1024*1024 && validMapping(value.mapping) && typeof value.css === 'string' && value.css.length <= 16*1024*1024
|
|
47
|
+
&& Array.isArray(value.compactNames) && value.compactNames.length <= 65536 && value.compactNames.every(item=>Array.isArray(item)&&item.length===2&&typeof item[0]==='string'&&item[0].length<=4096&&typeof item[1]==='string'&&/^p[\w-]{10}$/.test(item[1]))
|
|
48
|
+
&& Array.isArray(value.watchFiles) && value.watchFiles.length <= 4096 && value.watchFiles.every(file => typeof file==='string' && path.isAbsolute(file) && !file.includes('\0') && !/\.s[ac]ss$/i.test(file))
|
|
49
|
+
&& Array.isArray(value.assets) && value.assets.length <= 4096 && value.assets.every(asset => asset && typeof asset.name === 'string'
|
|
50
|
+
&& asset.name && asset.name === path.basename(asset.name) && !/[\\/\0<>:"|?*#]/.test(asset.name) && asset.name !== '.' && asset.name !== '..'
|
|
51
|
+
&& typeof asset.data === 'string' && asset.data.length<=24*1024*1024 && asset.data.length % 4 === 0 && /^[A-Za-z0-9+/]*={0,2}$/.test(asset.data))
|
|
52
|
+
&& new Set(value.assets.map(asset=>asset.name)).size===value.assets.length && value.assets.reduce((sum,asset)=>sum+asset.data.length,0)<=48*1024*1024;
|
|
30
53
|
|
|
31
54
|
async function compile(file) {
|
|
32
|
-
const dependencies=new Set([file]);
|
|
55
|
+
const dependencies=new Set([file]), globals = new Set();
|
|
33
56
|
const result = await bundle({
|
|
34
57
|
absWorkingDir: projectRoot,
|
|
35
58
|
stdin: { contents: `import styles from ${JSON.stringify(file)}; export default styles;`, resolveDir: projectRoot, loader: 'js' },
|
|
@@ -57,11 +80,13 @@ export function createCssModules({ projectRoot, stage, assetBase = '/_prnext/ass
|
|
|
57
80
|
});
|
|
58
81
|
esbuild.onLoad({ filter: /.*/, namespace: 'prnext-local-css' }, async args => {
|
|
59
82
|
const transformed = await processor.process(args.pluginData.source);
|
|
83
|
+
if (!dev) globalCssNames(transformed.css, {local:true}, globals);
|
|
60
84
|
for(const file of transformed.watchFiles)dependencies.add(file);
|
|
61
85
|
return { contents: transformed.css, loader: 'local-css', resolveDir: path.dirname(args.pluginData.source), watchFiles: transformed.watchFiles };
|
|
62
86
|
});
|
|
63
87
|
esbuild.onLoad({ filter: stylesheetPattern, namespace: 'file' }, async args => {
|
|
64
88
|
const transformed = await processor.process(args.path);
|
|
89
|
+
if (!dev) globalCssNames(transformed.css, {local:false}, globals);
|
|
65
90
|
for(const file of transformed.watchFiles)dependencies.add(file);
|
|
66
91
|
return { contents: transformed.css, loader: 'css', resolveDir: path.dirname(args.path), watchFiles: transformed.watchFiles };
|
|
67
92
|
});
|
|
@@ -77,18 +102,37 @@ export function createCssModules({ projectRoot, stage, assetBase = '/_prnext/ass
|
|
|
77
102
|
// The isolated context deliberately has no require, process, network, or filesystem.
|
|
78
103
|
const module = { exports: {} };
|
|
79
104
|
runInNewContext(javascript.text, { module }, { timeout: 1000, filename: 'prnext-css-exports.cjs' });
|
|
80
|
-
const
|
|
81
|
-
|
|
105
|
+
const compact = dev ? {mapping:module.exports.default,css:stylesheet.text,names:[]} : compactCssModule(stylesheet.text,module.exports.default,{globals});
|
|
106
|
+
const mapping = JSON.stringify({ ...compact.mapping, ...(await processor.process(file)).exports });
|
|
107
|
+
const assets = [];
|
|
82
108
|
for (const output of result.outputFiles) {
|
|
83
109
|
if (output === javascript || output === stylesheet) continue;
|
|
84
|
-
|
|
85
|
-
try{await writeFile(temporary,output.contents);await rename(temporary,target);}finally{await rm(temporary,{force:true});}
|
|
110
|
+
assets.push({name:path.basename(output.path),data:Buffer.from(output.contents).toString('base64')});
|
|
86
111
|
}
|
|
87
|
-
return { mapping, css:
|
|
112
|
+
return { mapping, css: compact.css,compactNames:compact.names,watchFiles:[...dependencies],assets };
|
|
88
113
|
}
|
|
89
114
|
|
|
90
115
|
function get(file) {
|
|
91
|
-
if (!cache.has(file)) cache.set(file,
|
|
116
|
+
if (!cache.has(file)) cache.set(file, (async () => {
|
|
117
|
+
eligibility ??= cacheEligible();
|
|
118
|
+
const enabled = await eligibility && /\.css$/i.test(file);
|
|
119
|
+
const result = await cachedCompilation({root:projectRoot,kind:'css-modules',inputs:[file],excluded:[path.resolve(projectRoot,config.distDir || '.prnext'),...(config.output==='export'?[path.join(projectRoot,'out')]:[])],styleTargets:true,settings:{file,assetBase,cssEngine:cssEngine || 'auto',...(enabled?{browserTargets:await browserTargets()}:{})},
|
|
120
|
+
enabled,reason:dev?'development':'custom-style-pipeline',validate:validResult,
|
|
121
|
+
bytes:value=>Buffer.byteLength(value.mapping)+Buffer.byteLength(value.css)+Buffer.byteLength(JSON.stringify(value.compactNames))+value.assets.reduce((sum,asset)=>sum+Buffer.byteLength(asset.data,'base64'),0),
|
|
122
|
+
produce:async()=>{const value=await compile(file);return {value,dependencies:value.watchFiles};}});
|
|
123
|
+
await mkdir(path.join(stage,'assets'),{recursive:true});
|
|
124
|
+
for(const [original,short] of result.compactNames || []) {
|
|
125
|
+
if(compactNames.has(short)&&compactNames.get(short)!==original)throw new Error('CSS Module compact-name collision across modules.');
|
|
126
|
+
compactNames.set(short,original);
|
|
127
|
+
}
|
|
128
|
+
for (const asset of result.assets) {
|
|
129
|
+
const target=path.join(stage,'assets',asset.name),temporary=target+'.'+randomUUID()+'.tmp';
|
|
130
|
+
try{await writeFile(temporary,Buffer.from(asset.data,'base64'));await rename(temporary,target);}finally{await rm(temporary,{force:true});}
|
|
131
|
+
}
|
|
132
|
+
// Keep the original public result and release replay-only binary payloads.
|
|
133
|
+
// The per-build mapping cache must not retain a base64 copy of every asset.
|
|
134
|
+
return {mapping:result.mapping,css:result.css,watchFiles:result.watchFiles};
|
|
135
|
+
})());
|
|
92
136
|
return cache.get(file);
|
|
93
137
|
}
|
|
94
138
|
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { lstat, readdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const INDEX = 'deployment-assets.json';
|
|
6
|
+
const MAX_FILES = 1024, MAX_BYTES = 64 * 1024 * 1024, MAX_FILE_BYTES = 8 * 1024 * 1024;
|
|
7
|
+
const MAX_AGE = 60 * 60 * 1000, MAX_GENERATIONS = 2;
|
|
8
|
+
// Only build-owned, immutable code/styles. Never JSON, maps, HTML, public/ or server/.
|
|
9
|
+
const safeName = value => typeof value === 'string' && value.length < 240 && /^[a-zA-Z0-9_.-]+-[a-zA-Z0-9]{8,64}\.(?:js|mjs|css|woff2?|ttf|otf|eot|png|jpe?g|avif|webp|gif|svg|ico|wasm)$/.test(value);
|
|
10
|
+
const assetKey = name => process.platform === 'win32' ? name.toLowerCase() : name;
|
|
11
|
+
const hash = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
12
|
+
async function regular(file, limit) {
|
|
13
|
+
const info = await lstat(file);
|
|
14
|
+
if (!info.isFile() || info.isSymbolicLink() || info.size > limit) return;
|
|
15
|
+
return info;
|
|
16
|
+
}
|
|
17
|
+
async function indexAt(directory) {
|
|
18
|
+
try {
|
|
19
|
+
if (!await regular(path.join(directory, INDEX), 1024 * 1024)) return;
|
|
20
|
+
const value = JSON.parse(await readFile(path.join(directory, INDEX), 'utf8'));
|
|
21
|
+
if (value.version !== 1 || !Array.isArray(value.generations) || value.generations.length > 6) return;
|
|
22
|
+
for (const generation of value.generations) {
|
|
23
|
+
if (typeof generation.id !== 'string' || generation.id.length > 128 || !Number.isSafeInteger(generation.createdAt) || !Array.isArray(generation.files) || generation.files.length > MAX_FILES) return;
|
|
24
|
+
const seen = new Set();
|
|
25
|
+
for (const file of generation.files) {
|
|
26
|
+
if (!safeName(file.name) || seen.has(assetKey(file.name)) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > MAX_FILE_BYTES || !/^[a-f0-9]{64}$/.test(file.hash)) return;
|
|
27
|
+
seen.add(assetKey(file.name));
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return value;
|
|
31
|
+
} catch (error) { if (!['ENOENT', 'ENOTDIR'].includes(error.code)) { /* Corrupt previous metadata is an optional retention miss. */ } }
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Copy only public immutable assets into a stage, before its atomic publication. */
|
|
35
|
+
export async function retainDeploymentAssets({ stage, destination, manifest, policy, now = Date.now() }) {
|
|
36
|
+
if (manifest.dev) return { generations: 0, files: 0, bytes: 0 };
|
|
37
|
+
const maxAge = (policy && policy.maxAge !== undefined ? policy.maxAge * 1000 : MAX_AGE);
|
|
38
|
+
const maxGenerations = policy && policy.maxGenerations !== undefined ? policy.maxGenerations : MAX_GENERATIONS;
|
|
39
|
+
const maxBytes = policy && policy.maxBytes !== undefined ? policy.maxBytes : MAX_BYTES;
|
|
40
|
+
const assets = path.join(stage, 'assets');
|
|
41
|
+
const assetInfo = await lstat(assets);
|
|
42
|
+
if (!assetInfo.isDirectory() || assetInfo.isSymbolicLink()) throw new Error('Deployment assets must be a regular directory');
|
|
43
|
+
const current = { id: manifest.cacheId, createdAt: now, eligible: true, files: [] };
|
|
44
|
+
let indexedBytes = 0;
|
|
45
|
+
for (const name of (await readdir(assets)).sort()) {
|
|
46
|
+
if (!safeName(name)) {
|
|
47
|
+
// Maps/manifests do not participate in executable chunk graphs. Unknown
|
|
48
|
+
// content-addressed media and auxiliary directories make retention unsafe.
|
|
49
|
+
if (/\.(?:map|json|html|txt|gz|br)$/.test(name)) continue;
|
|
50
|
+
current.eligible = false; current.files = []; break;
|
|
51
|
+
}
|
|
52
|
+
const file = path.join(assets, name), info = await regular(file, MAX_FILE_BYTES);
|
|
53
|
+
if (!info || (indexedBytes += info.size) > 256 * 1024 * 1024) { current.eligible = false; current.files = []; break; }
|
|
54
|
+
const bytes = await readFile(file);
|
|
55
|
+
current.files.push({ name, bytes: bytes.length, hash: hash(bytes) });
|
|
56
|
+
if (current.files.length > MAX_FILES) { current.eligible = false; current.files = []; break; }
|
|
57
|
+
}
|
|
58
|
+
const previousRoot = await lstat(path.join(destination, 'assets')).catch(() => null);
|
|
59
|
+
const previous = !current.eligible || policy === false || !previousRoot?.isDirectory() || previousRoot.isSymbolicLink() ? undefined : await indexAt(destination), generations = [current], known = new Map(current.files.map(file => [assetKey(file.name), file]));
|
|
60
|
+
const retained = Object.create(null); let bytes = 0, count = 0;
|
|
61
|
+
for (const generation of previous?.generations || []) {
|
|
62
|
+
if (generation.eligible !== true || generations.length > maxGenerations || generation.id === current.id || generation.createdAt > now || now - generation.createdAt >= maxAge) continue;
|
|
63
|
+
if (generation.files.some(file => { const value = known.get(assetKey(file.name)); return value && (value.hash !== file.hash || value.bytes !== file.bytes); })) continue;
|
|
64
|
+
const candidates = generation.files.filter(file => !known.has(assetKey(file.name)));
|
|
65
|
+
const size = candidates.reduce((sum, file) => sum + file.bytes, 0);
|
|
66
|
+
if (bytes + size > maxBytes || count + candidates.length > MAX_FILES) continue;
|
|
67
|
+
const verified = [];
|
|
68
|
+
for (const file of candidates) {
|
|
69
|
+
const source = path.join(destination, 'assets', file.name);
|
|
70
|
+
try {
|
|
71
|
+
if (!await regular(source, MAX_FILE_BYTES)) continue;
|
|
72
|
+
const contents = await readFile(source);
|
|
73
|
+
if (contents.length !== file.bytes || hash(contents) !== file.hash) continue;
|
|
74
|
+
verified.push({file, contents});
|
|
75
|
+
} catch { /* An unreadable previous asset is an optional retention miss. */ }
|
|
76
|
+
}
|
|
77
|
+
// An incomplete previous generation cannot promise a working chunk graph.
|
|
78
|
+
if (verified.length !== candidates.length) continue;
|
|
79
|
+
for (const {file, contents} of verified) {
|
|
80
|
+
await writeFile(path.join(assets, file.name), contents, {flag:'wx'});
|
|
81
|
+
known.set(assetKey(file.name), file); retained[file.name] = generation.createdAt + maxAge;
|
|
82
|
+
bytes += file.bytes; count++;
|
|
83
|
+
}
|
|
84
|
+
generations.push(generation);
|
|
85
|
+
}
|
|
86
|
+
manifest.config.retainedAssets = retained;
|
|
87
|
+
await writeFile(path.join(stage, INDEX), JSON.stringify({ version: 1, generations }) + '\n');
|
|
88
|
+
return { generations: generations.length - 1, files: count, bytes };
|
|
89
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
const defaults=Object.freeze({maxAge:3600,maxGenerations:2,maxBytes:64*1024*1024});
|
|
2
|
+
const limits={maxAge:[1,86400],maxGenerations:[1,5],maxBytes:[1024,256*1024*1024]};
|
|
3
|
+
|
|
4
|
+
/** Retain only bounded, immutable public assets; false revokes all carry-over. */
|
|
5
|
+
export function validateDeploymentRetention(value){
|
|
6
|
+
if(value===false)return false;
|
|
7
|
+
if(value===undefined)return {...defaults};
|
|
8
|
+
if(!value||typeof value!=='object'||Array.isArray(value)||![Object.prototype,null].includes(Object.getPrototypeOf(value)))throw new TypeError('deploymentRetention must be false or an object.');
|
|
9
|
+
const output={...defaults};
|
|
10
|
+
for(const key of Reflect.ownKeys(value)){
|
|
11
|
+
if(!Object.hasOwn(limits,key))throw new TypeError(`deploymentRetention.${String(key)} is not supported.`);
|
|
12
|
+
if(value[key]===undefined)continue;
|
|
13
|
+
const [minimum,maximum]=limits[key];
|
|
14
|
+
if(!Number.isSafeInteger(value[key])||value[key]<minimum||value[key]>maximum)throw new TypeError(`deploymentRetention.${key} must be an integer from ${minimum} to ${maximum}.`);
|
|
15
|
+
output[key]=value[key];
|
|
16
|
+
}
|
|
17
|
+
return output;
|
|
18
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
import { copyFile, link, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { rename } from '../runtime/fs.mjs';
|
|
5
|
+
|
|
6
|
+
export const STYLE_OVERLAY_LIMITS = Object.freeze({ files: 4096, bytes: 64 * 1024 * 1024, catalogueBytes: 4 * 1024 * 1024, references: 16384 });
|
|
7
|
+
const digest = data => createHash('sha256').update(data).digest('hex');
|
|
8
|
+
const filename = name => typeof name === 'string' && name.length > 0 && name.length <= 255 && name !== '.' && name !== '..'
|
|
9
|
+
&& !/[\\/\x00-\x1f<>:"|?*#]/.test(name) && path.basename(name) === name;
|
|
10
|
+
const inside = (root, file) => { const relative = path.relative(root, file); return relative !== '' && !path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep); };
|
|
11
|
+
|
|
12
|
+
export class StyleOverlayFallback extends Error {
|
|
13
|
+
constructor(code) { super(code); this.code = code; }
|
|
14
|
+
}
|
|
15
|
+
const fallback = code => { throw new StyleOverlayFallback(code); };
|
|
16
|
+
|
|
17
|
+
function assetsFor(bundles) {
|
|
18
|
+
const assets = new Map();
|
|
19
|
+
let references = 0, bytes = 0;
|
|
20
|
+
for (const files of bundles.values()) for (const file of files) {
|
|
21
|
+
if (++references > STYLE_OVERLAY_LIMITS.references) fallback('style-overlay-budget');
|
|
22
|
+
if (!file || !filename(file.name) || !Number.isSafeInteger(file.bytes) || file.bytes < 0 || file.bytes > STYLE_OVERLAY_LIMITS.bytes || !/^[a-f0-9]{64}$/.test(file.sha256)) fallback('invalid-style-overlay');
|
|
23
|
+
const previous = assets.get(file.name);
|
|
24
|
+
if (previous && (previous.bytes !== file.bytes || previous.sha256 !== file.sha256)) fallback('conflicting-style-overlay');
|
|
25
|
+
if (!previous) {
|
|
26
|
+
assets.set(file.name, file);
|
|
27
|
+
bytes += file.bytes;
|
|
28
|
+
if (assets.size > STYLE_OVERLAY_LIMITS.files || bytes > STYLE_OVERLAY_LIMITS.bytes) fallback('style-overlay-budget');
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return assets;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function catalogue(previous, plans) {
|
|
35
|
+
const bundles = new Map();
|
|
36
|
+
if (!previous) return bundles;
|
|
37
|
+
if (previous.version !== 1 || typeof previous.baseDirectory !== 'string' || previous.baseDirectory.length > 4096
|
|
38
|
+
|| !Array.isArray(previous.bundles) || previous.bundles.length > STYLE_OVERLAY_LIMITS.files) fallback('invalid-style-overlay');
|
|
39
|
+
let references = 0;
|
|
40
|
+
for (const entry of previous.bundles) {
|
|
41
|
+
if (!Array.isArray(entry) || entry.length !== 2 || !plans.has(entry[0]) || bundles.has(entry[0]) || !Array.isArray(entry[1]) || entry[1].length > STYLE_OVERLAY_LIMITS.files) fallback('invalid-style-overlay');
|
|
42
|
+
references += entry[1].length;
|
|
43
|
+
if (references > STYLE_OVERLAY_LIMITS.references) fallback('style-overlay-budget');
|
|
44
|
+
if (!entry[1].some(file => file?.name === entry[0])) fallback('invalid-style-overlay');
|
|
45
|
+
bundles.set(entry[0], entry[1]);
|
|
46
|
+
}
|
|
47
|
+
assetsFor(bundles);
|
|
48
|
+
if (Buffer.byteLength(JSON.stringify(previous)) > STYLE_OVERLAY_LIMITS.catalogueBytes) fallback('style-overlay-budget');
|
|
49
|
+
return bundles;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function inspect(file) {
|
|
53
|
+
const info = await lstat(file);
|
|
54
|
+
if (!info.isFile() || info.size > STYLE_OVERLAY_LIMITS.bytes) fallback('invalid-style-overlay');
|
|
55
|
+
const data = await readFile(file);
|
|
56
|
+
return { name: path.basename(file), bytes: data.byteLength, sha256: digest(data) };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** A CSS generation owns only explicitly catalogued stylesheet assets. */
|
|
60
|
+
export async function createStyleOverlay({ root, source, destination, baseSource = source, manifest, plans, selected }) {
|
|
61
|
+
const previous = manifest.devStyles.overlay;
|
|
62
|
+
if (!previous && manifest.devStyles.baseClient) fallback('missing-style-overlay');
|
|
63
|
+
if (previous && !manifest.devStyles.baseClient) fallback('invalid-style-overlay');
|
|
64
|
+
if (!Array.isArray(plans) || plans.length > STYLE_OVERLAY_LIMITS.files) fallback('style-overlay-budget');
|
|
65
|
+
const names = new Set(plans.map(([name]) => name));
|
|
66
|
+
if (names.size !== plans.length || ![...names].every(filename)) fallback('invalid-style-overlay');
|
|
67
|
+
const bundles = catalogue(previous, names), previousAssets = assetsFor(bundles);
|
|
68
|
+
const selectedNames = new Set(selected.map(([name]) => name));
|
|
69
|
+
if ([...selectedNames].some(name => !names.has(name))) fallback('invalid-style-overlay');
|
|
70
|
+
for (const route of manifest.routes || []) for (const url of route.css || []) {
|
|
71
|
+
if (url.includes('?v=') && !bundles.has(path.posix.basename(url.split('?')[0]))) fallback('missing-style-overlay');
|
|
72
|
+
}
|
|
73
|
+
const [canonicalRoot, canonicalSource, canonicalBase, parent, sourceAssets] = await Promise.all([realpath(root), realpath(source), realpath(baseSource), realpath(path.dirname(destination)), realpath(path.join(source, 'assets'))]).catch(error => {
|
|
74
|
+
if (error.code === 'ENOENT') fallback('missing-style-overlay');
|
|
75
|
+
throw error;
|
|
76
|
+
});
|
|
77
|
+
// The destination does not exist yet. Its existing parent supplies the real
|
|
78
|
+
// identity, including macOS /var aliases and Windows short-name prefixes.
|
|
79
|
+
const canonicalDestination = path.join(parent, path.basename(destination));
|
|
80
|
+
if (!inside(canonicalRoot, canonicalSource) || !inside(canonicalRoot, canonicalBase)
|
|
81
|
+
|| parent !== canonicalRoot && !inside(canonicalRoot, parent) || !inside(canonicalRoot, canonicalDestination)
|
|
82
|
+
|| canonicalDestination === canonicalSource || canonicalDestination === canonicalBase
|
|
83
|
+
|| inside(canonicalDestination, canonicalSource) || inside(canonicalSource, canonicalDestination)
|
|
84
|
+
|| inside(canonicalDestination, canonicalBase) || inside(canonicalBase, canonicalDestination)
|
|
85
|
+
|| sourceAssets !== path.join(canonicalSource, 'assets')) fallback('invalid-style-overlay-directory');
|
|
86
|
+
const baseDirectory = path.relative(canonicalRoot, canonicalBase).replaceAll(path.sep, '/');
|
|
87
|
+
if (previous ? previous.baseDirectory !== baseDirectory : canonicalSource !== canonicalBase) fallback('invalid-style-overlay-base');
|
|
88
|
+
const assetsDirectory = path.join(destination, 'assets');
|
|
89
|
+
let owned = false;
|
|
90
|
+
async function inherit(file) {
|
|
91
|
+
const from = path.join(source, 'assets', file.name), target = path.join(assetsDirectory, file.name);
|
|
92
|
+
let actual;
|
|
93
|
+
try { actual = await inspect(from); } catch (error) { if (error.code === 'ENOENT') fallback('missing-style-overlay-asset'); throw error; }
|
|
94
|
+
if (actual.bytes !== file.bytes || actual.sha256 !== file.sha256) fallback('invalid-style-overlay-asset');
|
|
95
|
+
try { await link(from, target); }
|
|
96
|
+
catch (error) {
|
|
97
|
+
if (!['EXDEV', 'EPERM', 'ENOTSUP', 'EACCES'].includes(error.code)) throw error;
|
|
98
|
+
await copyFile(from, target);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
try {
|
|
102
|
+
await mkdir(destination);
|
|
103
|
+
owned = true;
|
|
104
|
+
await mkdir(assetsDirectory);
|
|
105
|
+
for (const name of selected.map(([name]) => name)) bundles.delete(name);
|
|
106
|
+
for (const file of assetsFor(bundles).values()) await inherit(file);
|
|
107
|
+
} catch (error) {
|
|
108
|
+
if (owned) await rm(destination, { recursive: true, force: true });
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
async recordBundle(name, result) {
|
|
114
|
+
if (!selectedNames.has(name)) fallback('invalid-style-overlay-output');
|
|
115
|
+
const files = new Map();
|
|
116
|
+
for (const output of result.outputFiles) {
|
|
117
|
+
if (path.dirname(path.resolve(output.path)) !== path.resolve(assetsDirectory) || !filename(path.basename(output.path))) fallback('invalid-style-overlay-output');
|
|
118
|
+
const file = { name: path.basename(output.path), bytes: output.contents.byteLength, sha256: digest(output.contents) };
|
|
119
|
+
files.set(file.name, file);
|
|
120
|
+
}
|
|
121
|
+
if (!files.has(name)) fallback('invalid-style-overlay-output');
|
|
122
|
+
// CSS Module compilation emits its URL assets before the outer CSS
|
|
123
|
+
// bundle. Its absolute, external URL-token imports identify exactly the
|
|
124
|
+
// emitted images/fonts to retain, without scanning the JavaScript build.
|
|
125
|
+
const prefix = manifest.config.assetBase.replace(/\/$/, '') + '/';
|
|
126
|
+
for (const output of Object.values(result.metafile.outputs)) for (const item of output.imports || []) {
|
|
127
|
+
if (item.kind !== 'url-token' || !item.external || !item.path.startsWith(prefix)) continue;
|
|
128
|
+
let name;
|
|
129
|
+
try { name = decodeURIComponent(item.path.slice(prefix.length).split(/[?#]/)[0]); }
|
|
130
|
+
catch { fallback('invalid-style-overlay-output'); }
|
|
131
|
+
if (!filename(name) || files.has(name)) continue;
|
|
132
|
+
let file;
|
|
133
|
+
try { file = await inspect(path.join(assetsDirectory, name)); }
|
|
134
|
+
catch (error) {
|
|
135
|
+
if (error.code !== 'ENOENT') throw error;
|
|
136
|
+
const previous = previousAssets.get(name);
|
|
137
|
+
if (!previous) continue; // An unchanged base or public asset stays in its original generation.
|
|
138
|
+
await inherit(previous);
|
|
139
|
+
file = previous;
|
|
140
|
+
}
|
|
141
|
+
files.set(name, file);
|
|
142
|
+
}
|
|
143
|
+
bundles.set(name, [...files.values()]);
|
|
144
|
+
assetsFor(bundles); // Validate the aggregate budget before publishing bytes.
|
|
145
|
+
for (const output of result.outputFiles) {
|
|
146
|
+
const file = files.get(path.basename(output.path));
|
|
147
|
+
let existing;
|
|
148
|
+
try { existing = await inspect(output.path); } catch (error) { if (error.code !== 'ENOENT') throw error; }
|
|
149
|
+
if (existing) {
|
|
150
|
+
if (existing.bytes !== file.bytes || existing.sha256 !== file.sha256) fallback('conflicting-style-overlay');
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const temporary = output.path + '.' + randomUUID() + '.tmp';
|
|
154
|
+
try { await writeFile(temporary, output.contents); await rename(temporary, output.path); }
|
|
155
|
+
finally { await rm(temporary, { force: true }); }
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
async finish() {
|
|
159
|
+
const assets = assetsFor(bundles);
|
|
160
|
+
const value = { version: 1, baseDirectory, bundles: [...bundles] };
|
|
161
|
+
if (Buffer.byteLength(JSON.stringify(value)) > STYLE_OVERLAY_LIMITS.catalogueBytes) fallback('style-overlay-budget');
|
|
162
|
+
// This directory was created above for CSS only. Remove module assets
|
|
163
|
+
// that no current bundle references, rather than accumulating old URLs.
|
|
164
|
+
const entries = await readdir(assetsDirectory, { withFileTypes: true });
|
|
165
|
+
if (entries.length > STYLE_OVERLAY_LIMITS.references) fallback('style-overlay-budget');
|
|
166
|
+
for (const entry of entries) {
|
|
167
|
+
if (!entry.isFile() || !filename(entry.name)) fallback('invalid-style-overlay-output');
|
|
168
|
+
if (!assets.has(entry.name)) await rm(path.join(assetsDirectory, entry.name));
|
|
169
|
+
}
|
|
170
|
+
return value;
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|