arcway 0.1.20 → 0.1.22
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 +69 -8
- package/client/dynamic.js +48 -0
- package/client/router.js +13 -5
- package/package.json +3 -1
- package/server/boot/hooks.js +19 -0
- package/server/boot/index.js +18 -8
- package/server/context.js +10 -0
- package/server/db/index.js +9 -0
- package/server/events/handler.js +0 -1
- package/server/pages/arcway-endpoint.js +58 -0
- package/server/pages/build-client.js +110 -25
- package/server/pages/build-context.js +1 -1
- package/server/pages/build-css.js +23 -9
- package/server/pages/build-manifest.js +5 -4
- package/server/pages/build-server.js +80 -63
- package/server/pages/build.js +42 -4
- package/server/pages/chunk-graph.js +59 -0
- package/server/pages/compress.js +103 -0
- package/server/pages/discovery.js +99 -0
- package/server/pages/handler.js +107 -3
- package/server/pages/lazy-context.js +640 -0
- package/server/pages/pages-router.js +16 -21
- package/server/pages/ssr.js +16 -4
- package/server/pages/static.js +51 -4
- package/server/pages/watcher.js +59 -76
- package/server/static/index.js +1 -1
- package/server/ws/ws-router.js +45 -2
|
@@ -53,7 +53,7 @@ function computeCssCoarseKey({ minify, fontFaceCss, stylesPath }) {
|
|
|
53
53
|
);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
-
async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir) {
|
|
56
|
+
async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir, limit = (fn) => fn()) {
|
|
57
57
|
try {
|
|
58
58
|
await fs.access(pagesDir);
|
|
59
59
|
} catch {
|
|
@@ -61,6 +61,12 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
const clientDir = path.join(outDir, 'client');
|
|
64
|
+
// rootDir == the project root — same directory tailwind v4's auto-content
|
|
65
|
+
// detection walks up to find (it stops at the nearest package.json). Keep
|
|
66
|
+
// the scan root separate from the cache-root default: if the caller didn't
|
|
67
|
+
// pass rootDir, fall back to pagesDir for scanning (narrower, safe for
|
|
68
|
+
// tests) while still letting the cache default to outDir's parent.
|
|
69
|
+
const tokenScanRoot = rootDir ?? pagesDir;
|
|
64
70
|
rootDir = rootDir ?? path.dirname(outDir);
|
|
65
71
|
const coarseKey = computeCssCoarseKey({ minify, fontFaceCss, stylesPath });
|
|
66
72
|
|
|
@@ -73,14 +79,22 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
|
|
|
73
79
|
for (const f of cssFiles) inputs.push(f);
|
|
74
80
|
|
|
75
81
|
// Virtual input: a digest over every string-literal token found in the
|
|
76
|
-
// JSX/TS source tree under
|
|
77
|
-
// only on which tokens appear in scanned files — not on surrounding
|
|
78
|
-
// structure — so the digest captures the only source signal that
|
|
79
|
-
// matters for CSS output. Editing handler logic, comments, or
|
|
80
|
-
// leaves this digest unchanged and the CSS cache is reused.
|
|
81
|
-
|
|
82
|
+
// JSX/TS source tree under tokenScanRoot. Tailwind's class-name emission
|
|
83
|
+
// depends only on which tokens appear in scanned files — not on surrounding
|
|
84
|
+
// code structure — so the digest captures the only source signal that
|
|
85
|
+
// actually matters for CSS output. Editing handler logic, comments, or
|
|
86
|
+
// whitespace leaves this digest unchanged and the CSS cache is reused.
|
|
87
|
+
//
|
|
88
|
+
// Must match tailwind v4's effective scan scope: auto-content detection
|
|
89
|
+
// walks up from the CSS entry file to the nearest package.json, so a new
|
|
90
|
+
// arbitrary-value class in a sibling dir (e.g. components/ alongside pages/)
|
|
91
|
+
// IS picked up by tailwind but must also be reflected in this digest —
|
|
92
|
+
// otherwise an incremental rebuild hits the cache and silently restores
|
|
93
|
+
// stale CSS missing the new rule. Scanning rootDir (the project root)
|
|
94
|
+
// covers pagesDir + every sibling source tree, matching tailwind's scope.
|
|
95
|
+
const classTokensDigest = await hashClassTokens(tokenScanRoot);
|
|
82
96
|
const virtualInputs = [
|
|
83
|
-
{ name: `${path.resolve(
|
|
97
|
+
{ name: `${path.resolve(tokenScanRoot)}::class-tokens`, digest: classTokensDigest },
|
|
84
98
|
];
|
|
85
99
|
|
|
86
100
|
const lookup = await lookupMultiFileCache({ rootDir, kind: 'css', coarseKey, virtualInputs });
|
|
@@ -112,7 +126,7 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss,
|
|
|
112
126
|
});
|
|
113
127
|
let css = fontFaceCss ? `${fontFaceCss}\n\n${result.css}` : result.css;
|
|
114
128
|
if (minify) {
|
|
115
|
-
const minified = await esbuild.transform(css, { loader: 'css', minify: true });
|
|
129
|
+
const minified = await limit(() => esbuild.transform(css, { loader: 'css', minify: true }));
|
|
116
130
|
css = minified.code;
|
|
117
131
|
}
|
|
118
132
|
const hash = crypto.createHash('sha256').update(css).digest('hex').slice(0, 8);
|
|
@@ -34,6 +34,8 @@ function generateManifest(
|
|
|
34
34
|
const middlewareServerBundlePaths = middlewareChain
|
|
35
35
|
.map((m) => middlewareServerBundles.get(m.dirPath))
|
|
36
36
|
.filter((p) => p !== void 0);
|
|
37
|
+
const sharedChunks = clientResult.entryChunks?.get(page.pattern) ?? [];
|
|
38
|
+
const sharedCssChunks = clientResult.entryCssChunks?.get(page.pattern) ?? [];
|
|
37
39
|
entries.push({
|
|
38
40
|
pattern: page.pattern,
|
|
39
41
|
paramNames: page.paramNames,
|
|
@@ -45,11 +47,10 @@ function generateManifest(
|
|
|
45
47
|
layoutClientBundles: layoutClientBundlePaths,
|
|
46
48
|
loadingClientBundles: loadingClientBundlePaths,
|
|
47
49
|
middlewareServerBundles: middlewareServerBundlePaths,
|
|
50
|
+
sharedChunks,
|
|
51
|
+
sharedCssChunks,
|
|
48
52
|
});
|
|
49
53
|
}
|
|
50
|
-
return {
|
|
51
|
-
entries,
|
|
52
|
-
sharedChunks: clientResult.sharedChunks,
|
|
53
|
-
};
|
|
54
|
+
return { entries };
|
|
54
55
|
}
|
|
55
56
|
export { generateManifest };
|
|
@@ -45,76 +45,89 @@ function plainEsbuildOptions(entry, outFile, target) {
|
|
|
45
45
|
};
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
// Identity wrapper for the `limit` option. When `buildPages()` passes a shared
|
|
49
|
+
// `p-limit` instance, every esbuild invocation inside this file passes through
|
|
50
|
+
// it so the whole build respects a single concurrency budget. When callers (or
|
|
51
|
+
// direct tests) omit `limit`, the identity wrapper keeps today's unbounded
|
|
52
|
+
// fan-out intact.
|
|
53
|
+
const identityLimit = (fn) => fn();
|
|
54
|
+
|
|
55
|
+
async function buildServerBundles(pages, outDir, target, { rootDir, devMode, minify, limit = identityLimit } = {}) {
|
|
49
56
|
const serverDir = path.join(outDir, 'server');
|
|
50
57
|
const resultMap = new Map();
|
|
51
58
|
await Promise.all(
|
|
52
|
-
pages.map(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
59
|
+
pages.map((page) =>
|
|
60
|
+
limit(async () => {
|
|
61
|
+
const outName = patternToFileName(page.pattern);
|
|
62
|
+
const outFile = path.join(serverDir, `${outName}.js`);
|
|
63
|
+
await buildWithCache({
|
|
64
|
+
rootDir: rootDir ?? outDir,
|
|
65
|
+
kind: 'server',
|
|
66
|
+
entryPath: page.filePath,
|
|
67
|
+
outFile,
|
|
68
|
+
esbuildOptions: jsxEsbuildOptions(page.filePath, outFile, target),
|
|
69
|
+
esbuild,
|
|
70
|
+
devMode,
|
|
71
|
+
minify,
|
|
72
|
+
});
|
|
73
|
+
resultMap.set(page.pattern, path.relative(outDir, outFile).replace(/\\/g, '/'));
|
|
74
|
+
}),
|
|
75
|
+
),
|
|
67
76
|
);
|
|
68
77
|
return resultMap;
|
|
69
78
|
}
|
|
70
79
|
|
|
71
|
-
async function buildLayoutServerBundles(layouts, outDir, target, { rootDir, devMode, minify } = {}) {
|
|
80
|
+
async function buildLayoutServerBundles(layouts, outDir, target, { rootDir, devMode, minify, limit = identityLimit } = {}) {
|
|
72
81
|
const serverDir = path.join(outDir, 'server');
|
|
73
82
|
const resultMap = new Map();
|
|
74
83
|
await Promise.all(
|
|
75
|
-
layouts.map(
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
84
|
+
layouts.map((layout) =>
|
|
85
|
+
limit(async () => {
|
|
86
|
+
const outName = layoutDirToFileName(layout.dirPath);
|
|
87
|
+
const outFile = path.join(serverDir, `_layout_${outName}.js`);
|
|
88
|
+
await buildWithCache({
|
|
89
|
+
rootDir: rootDir ?? outDir,
|
|
90
|
+
kind: 'layout',
|
|
91
|
+
entryPath: layout.filePath,
|
|
92
|
+
outFile,
|
|
93
|
+
esbuildOptions: jsxEsbuildOptions(layout.filePath, outFile, target),
|
|
94
|
+
esbuild,
|
|
95
|
+
devMode,
|
|
96
|
+
minify,
|
|
97
|
+
});
|
|
98
|
+
resultMap.set(layout.dirPath, path.relative(outDir, outFile).replace(/\\/g, '/'));
|
|
99
|
+
}),
|
|
100
|
+
),
|
|
90
101
|
);
|
|
91
102
|
return resultMap;
|
|
92
103
|
}
|
|
93
104
|
|
|
94
|
-
async function buildMiddlewareServerBundles(middlewares, outDir, target, { rootDir, devMode, minify } = {}) {
|
|
105
|
+
async function buildMiddlewareServerBundles(middlewares, outDir, target, { rootDir, devMode, minify, limit = identityLimit } = {}) {
|
|
95
106
|
const serverDir = path.join(outDir, 'server');
|
|
96
107
|
const resultMap = new Map();
|
|
97
108
|
await Promise.all(
|
|
98
|
-
middlewares.map(
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
109
|
+
middlewares.map((mw) =>
|
|
110
|
+
limit(async () => {
|
|
111
|
+
const outName = layoutDirToFileName(mw.dirPath);
|
|
112
|
+
const outFile = path.join(serverDir, `_middleware_${outName}.js`);
|
|
113
|
+
await buildWithCache({
|
|
114
|
+
rootDir: rootDir ?? outDir,
|
|
115
|
+
kind: 'middleware',
|
|
116
|
+
entryPath: mw.filePath,
|
|
117
|
+
outFile,
|
|
118
|
+
esbuildOptions: plainEsbuildOptions(mw.filePath, outFile, target),
|
|
119
|
+
esbuild,
|
|
120
|
+
devMode,
|
|
121
|
+
minify,
|
|
122
|
+
});
|
|
123
|
+
resultMap.set(mw.dirPath, path.relative(outDir, outFile).replace(/\\/g, '/'));
|
|
124
|
+
}),
|
|
125
|
+
),
|
|
113
126
|
);
|
|
114
127
|
return resultMap;
|
|
115
128
|
}
|
|
116
129
|
|
|
117
|
-
async function buildErrorPageBundles(errorPages, outDir, target, { rootDir, devMode, minify } = {}) {
|
|
130
|
+
async function buildErrorPageBundles(errorPages, outDir, target, { rootDir, devMode, minify, limit = identityLimit } = {}) {
|
|
118
131
|
const serverDir = path.join(outDir, 'server');
|
|
119
132
|
const result = {};
|
|
120
133
|
const builds = [];
|
|
@@ -125,20 +138,22 @@ async function buildErrorPageBundles(errorPages, outDir, target, { rootDir, devM
|
|
|
125
138
|
builds.push({ filePath: errorPages.notFoundPage, outName: '_404', key: 'notFound' });
|
|
126
139
|
}
|
|
127
140
|
await Promise.all(
|
|
128
|
-
builds.map(
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
141
|
+
builds.map((build) =>
|
|
142
|
+
limit(async () => {
|
|
143
|
+
const outFile = path.join(serverDir, `${build.outName}.js`);
|
|
144
|
+
await buildWithCache({
|
|
145
|
+
rootDir: rootDir ?? outDir,
|
|
146
|
+
kind: 'errorPage',
|
|
147
|
+
entryPath: build.filePath,
|
|
148
|
+
outFile,
|
|
149
|
+
esbuildOptions: jsxEsbuildOptions(build.filePath, outFile, target),
|
|
150
|
+
esbuild,
|
|
151
|
+
devMode,
|
|
152
|
+
minify,
|
|
153
|
+
});
|
|
154
|
+
result[build.key] = path.relative(outDir, outFile);
|
|
155
|
+
}),
|
|
156
|
+
),
|
|
142
157
|
);
|
|
143
158
|
return result;
|
|
144
159
|
}
|
|
@@ -148,6 +163,8 @@ export {
|
|
|
148
163
|
buildLayoutServerBundles,
|
|
149
164
|
buildMiddlewareServerBundles,
|
|
150
165
|
buildServerBundles,
|
|
166
|
+
jsxEsbuildOptions,
|
|
151
167
|
layoutDirToFileName,
|
|
152
168
|
patternToFileName,
|
|
169
|
+
plainEsbuildOptions,
|
|
153
170
|
};
|
package/server/pages/build.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import crypto from 'node:crypto';
|
|
4
|
+
import pLimit from 'p-limit';
|
|
4
5
|
import {
|
|
5
6
|
discoverPages,
|
|
6
7
|
discoverLayouts,
|
|
@@ -21,6 +22,33 @@ import { generateManifest } from './build-manifest.js';
|
|
|
21
22
|
import { resolveFonts } from './fonts.js';
|
|
22
23
|
import { buildHmrRuntimeBundle } from './hmr.js';
|
|
23
24
|
import { enforceCacheBudget } from './build-cache.js';
|
|
25
|
+
import { precompressAssets } from './compress.js';
|
|
26
|
+
// Resolve the effective build-concurrency bound for a `buildPages()` call.
|
|
27
|
+
// Precedence: explicit `options.concurrency` → `ARCWAY_BUILD_CONCURRENCY` env
|
|
28
|
+
// → `Infinity` (unbounded, today's behavior). Only positive integers count;
|
|
29
|
+
// anything else (NaN, 0, negatives, floats, non-numeric strings) falls through
|
|
30
|
+
// so a typo can't accidentally throttle a real user's build.
|
|
31
|
+
function resolveBuildConcurrency(optionValue) {
|
|
32
|
+
if (Number.isInteger(optionValue) && optionValue > 0) return optionValue;
|
|
33
|
+
const envRaw = process.env.ARCWAY_BUILD_CONCURRENCY;
|
|
34
|
+
if (envRaw) {
|
|
35
|
+
const trimmed = envRaw.trim();
|
|
36
|
+
if (/^\d+$/.test(trimmed)) {
|
|
37
|
+
const n = Number.parseInt(trimmed, 10);
|
|
38
|
+
if (Number.isInteger(n) && n > 0) return n;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Infinity;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// When concurrency is Infinity we return a zero-overhead identity wrapper so
|
|
45
|
+
// `limit(fn)` is byte-equivalent to calling `fn()` directly — no `p-limit`
|
|
46
|
+
// queue allocation, no semantic change from today's unbounded `Promise.all`.
|
|
47
|
+
function createLimit(concurrency) {
|
|
48
|
+
if (concurrency === Infinity) return (fn) => fn();
|
|
49
|
+
return pLimit(concurrency);
|
|
50
|
+
}
|
|
51
|
+
|
|
24
52
|
async function buildPages(options) {
|
|
25
53
|
const { rootDir } = options;
|
|
26
54
|
const pagesDir = options.pagesDir ?? path.join(rootDir, 'pages');
|
|
@@ -29,6 +57,7 @@ async function buildPages(options) {
|
|
|
29
57
|
const clientTarget = options.clientTarget ?? 'es2022';
|
|
30
58
|
const minify = options.minify ?? true;
|
|
31
59
|
const devMode = options.devMode ?? false;
|
|
60
|
+
const limit = createLimit(resolveBuildConcurrency(options.concurrency));
|
|
32
61
|
const [pages, layouts, loadings, middlewares, errorPages, stylesPath] = await Promise.all([
|
|
33
62
|
discoverPages(pagesDir),
|
|
34
63
|
discoverLayouts(pagesDir),
|
|
@@ -38,7 +67,7 @@ async function buildPages(options) {
|
|
|
38
67
|
discoverStyles(pagesDir),
|
|
39
68
|
]);
|
|
40
69
|
if (pages.length === 0) {
|
|
41
|
-
return { pageCount: 0, outDir, manifest: { entries: []
|
|
70
|
+
return { pageCount: 0, outDir, manifest: { entries: [] } };
|
|
42
71
|
}
|
|
43
72
|
// Build into a uniquely-suffixed staging directory and atomically swap into place
|
|
44
73
|
// only after every step succeeds. If the build fails partway through, the previous
|
|
@@ -58,7 +87,7 @@ async function buildPages(options) {
|
|
|
58
87
|
if (fontResult.fontFaceCss) fontFaceCss = fontResult.fontFaceCss;
|
|
59
88
|
if (fontResult.preloadHtml) fontPreloadHtml = fontResult.preloadHtml;
|
|
60
89
|
}
|
|
61
|
-
const serverCacheOpts = { rootDir, devMode, minify };
|
|
90
|
+
const serverCacheOpts = { rootDir, devMode, minify, limit };
|
|
62
91
|
builds.push(
|
|
63
92
|
buildServerBundles(pages, buildDir, serverTarget, serverCacheOpts),
|
|
64
93
|
buildLayoutServerBundles(layouts, buildDir, serverTarget, serverCacheOpts),
|
|
@@ -73,9 +102,10 @@ async function buildPages(options) {
|
|
|
73
102
|
loadings,
|
|
74
103
|
minify ? 'production' : 'development',
|
|
75
104
|
devMode,
|
|
105
|
+
limit,
|
|
76
106
|
),
|
|
77
107
|
buildErrorPageBundles(errorPages, buildDir, serverTarget, serverCacheOpts),
|
|
78
|
-
buildCssBundle(pagesDir, buildDir, minify, stylesPath, fontFaceCss, rootDir),
|
|
108
|
+
buildCssBundle(pagesDir, buildDir, minify, stylesPath, fontFaceCss, rootDir, limit),
|
|
79
109
|
);
|
|
80
110
|
if (devMode) builds.push(buildHmrRuntimeBundle(buildDir));
|
|
81
111
|
// Fail fast: `Promise.all` rejects the moment any build throws, so a broken
|
|
@@ -107,6 +137,14 @@ async function buildPages(options) {
|
|
|
107
137
|
path.join(buildDir, 'pages-manifest.json'),
|
|
108
138
|
JSON.stringify(manifest, null, 2),
|
|
109
139
|
);
|
|
140
|
+
// Pre-compress client assets before the atomic swap so the swapped-in
|
|
141
|
+
// directory already contains .br/.gz siblings and the static handler
|
|
142
|
+
// never sees a window where only the raw file is servable. Dev rebuilds
|
|
143
|
+
// skip this — brotli-11 is expensive and offers no runtime value under
|
|
144
|
+
// `npm run dev`, which serves identity anyway.
|
|
145
|
+
if (!devMode) {
|
|
146
|
+
await precompressAssets(path.join(buildDir, 'client'));
|
|
147
|
+
}
|
|
110
148
|
await atomicSwap(outDir, buildDir);
|
|
111
149
|
// Keep the on-disk cache bounded. Fire-and-forget so the build returns
|
|
112
150
|
// immediately; eviction is idempotent across concurrent builds and any
|
|
@@ -183,4 +221,4 @@ function rewriteMetafilePaths(metafile, fromDir, toDir) {
|
|
|
183
221
|
return { ...metafile, outputs };
|
|
184
222
|
}
|
|
185
223
|
import { patternToFileName } from './build-server.js';
|
|
186
|
-
export { buildPages, patternToFileName, buildPagesIdle };
|
|
224
|
+
export { buildPages, patternToFileName, buildPagesIdle, resolveBuildConcurrency, createLimit };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
|
|
3
|
+
// Given an esbuild metafile and the directory its outputs were emitted into,
|
|
4
|
+
// compute the transitive closure of `chunks/*` outputs reachable from each
|
|
5
|
+
// entry-point output. Returned paths are `client/…` relative to the page
|
|
6
|
+
// build root so they can be dropped straight into the manifest/SSR script
|
|
7
|
+
// tags without additional rewriting.
|
|
8
|
+
//
|
|
9
|
+
// JS and CSS chunks are kept in separate buckets because SSR renders them
|
|
10
|
+
// with different tag types (`<script type="module">` vs `<link rel="stylesheet">`).
|
|
11
|
+
// Mixing them produces a strict-MIME console error on every page load because
|
|
12
|
+
// browsers refuse to execute `text/css` as a module script.
|
|
13
|
+
function computeEntryChunks(metafile, clientDir) {
|
|
14
|
+
const outputs = metafile?.outputs ?? {};
|
|
15
|
+
const resolvedClientDir = path.resolve(clientDir);
|
|
16
|
+
|
|
17
|
+
const chunkKeys = new Set();
|
|
18
|
+
const entryKeys = new Set();
|
|
19
|
+
for (const [key, meta] of Object.entries(outputs)) {
|
|
20
|
+
if (key.endsWith('.map')) continue;
|
|
21
|
+
const abs = path.resolve(key);
|
|
22
|
+
const rel = path.relative(resolvedClientDir, abs).replace(/\\/g, '/');
|
|
23
|
+
if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) continue;
|
|
24
|
+
if (rel.startsWith('chunks/')) {
|
|
25
|
+
chunkKeys.add(key);
|
|
26
|
+
} else if (meta?.entryPoint) {
|
|
27
|
+
entryKeys.add(key);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const result = new Map();
|
|
32
|
+
for (const entryKey of entryKeys) {
|
|
33
|
+
const visited = new Set([entryKey]);
|
|
34
|
+
const queue = [entryKey];
|
|
35
|
+
const js = [];
|
|
36
|
+
const css = [];
|
|
37
|
+
while (queue.length > 0) {
|
|
38
|
+
const cur = queue.shift();
|
|
39
|
+
const meta = outputs[cur];
|
|
40
|
+
if (!meta) continue;
|
|
41
|
+
for (const imp of meta.imports ?? []) {
|
|
42
|
+
const target = imp.path;
|
|
43
|
+
if (!target || visited.has(target)) continue;
|
|
44
|
+
visited.add(target);
|
|
45
|
+
queue.push(target);
|
|
46
|
+
if (chunkKeys.has(target)) {
|
|
47
|
+
const rel = path.relative(resolvedClientDir, path.resolve(target)).replace(/\\/g, '/');
|
|
48
|
+
const outRel = `client/${rel}`;
|
|
49
|
+
if (outRel.endsWith('.css')) css.push(outRel);
|
|
50
|
+
else js.push(outRel);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
result.set(entryKey, { js, css });
|
|
55
|
+
}
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export { computeEntryChunks };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { brotliCompress, gzip, constants } from 'node:zlib';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
|
|
6
|
+
const brotliCompressAsync = promisify(brotliCompress);
|
|
7
|
+
const gzipAsync = promisify(gzip);
|
|
8
|
+
|
|
9
|
+
const DEFAULT_EXTENSIONS = new Set(['.js', '.css', '.map', '.svg', '.json', '.html']);
|
|
10
|
+
const PRECOMPRESSED_SUFFIXES = ['.br', '.gz'];
|
|
11
|
+
const DEFAULT_THRESHOLD = 1024;
|
|
12
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
13
|
+
|
|
14
|
+
async function precompressAssets(clientDir, opts = {}) {
|
|
15
|
+
const threshold = opts.threshold ?? DEFAULT_THRESHOLD;
|
|
16
|
+
const concurrency = Math.max(1, opts.concurrency ?? DEFAULT_CONCURRENCY);
|
|
17
|
+
const extensions = opts.extensions ? new Set(opts.extensions) : DEFAULT_EXTENSIONS;
|
|
18
|
+
|
|
19
|
+
const targets = [];
|
|
20
|
+
await walk(clientDir, extensions, targets);
|
|
21
|
+
|
|
22
|
+
const jobs = [];
|
|
23
|
+
for (const file of targets) {
|
|
24
|
+
let stat;
|
|
25
|
+
try {
|
|
26
|
+
stat = await fs.stat(file);
|
|
27
|
+
} catch {
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (stat.size < threshold) continue;
|
|
31
|
+
|
|
32
|
+
const mtimeMs = stat.mtimeMs;
|
|
33
|
+
if (!(await isFresh(file + '.br', mtimeMs))) {
|
|
34
|
+
jobs.push({ file, outPath: file + '.br', kind: 'br' });
|
|
35
|
+
}
|
|
36
|
+
if (!(await isFresh(file + '.gz', mtimeMs))) {
|
|
37
|
+
jobs.push({ file, outPath: file + '.gz', kind: 'gz' });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
let cursor = 0;
|
|
42
|
+
const runOne = async () => {
|
|
43
|
+
while (cursor < jobs.length) {
|
|
44
|
+
const job = jobs[cursor++];
|
|
45
|
+
await compressOne(job);
|
|
46
|
+
}
|
|
47
|
+
};
|
|
48
|
+
const workerCount = Math.min(concurrency, jobs.length);
|
|
49
|
+
await Promise.all(Array.from({ length: workerCount }, runOne));
|
|
50
|
+
|
|
51
|
+
return { compressed: jobs.length };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function compressOne({ file, outPath, kind }) {
|
|
55
|
+
const data = await fs.readFile(file);
|
|
56
|
+
let encoded;
|
|
57
|
+
if (kind === 'br') {
|
|
58
|
+
encoded = await brotliCompressAsync(data, {
|
|
59
|
+
params: {
|
|
60
|
+
[constants.BROTLI_PARAM_QUALITY]: constants.BROTLI_MAX_QUALITY,
|
|
61
|
+
[constants.BROTLI_PARAM_SIZE_HINT]: data.length,
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
} else {
|
|
65
|
+
encoded = await gzipAsync(data, { level: constants.Z_BEST_COMPRESSION });
|
|
66
|
+
}
|
|
67
|
+
await fs.writeFile(outPath, encoded);
|
|
68
|
+
// The artifact naturally picks up a write-time mtime strictly later than
|
|
69
|
+
// the source mtime, which is exactly what `isFresh` relies on.
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function isFresh(outPath, srcMtimeMs) {
|
|
73
|
+
try {
|
|
74
|
+
const stat = await fs.stat(outPath);
|
|
75
|
+
return stat.mtimeMs >= srcMtimeMs;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function walk(dir, extensions, acc) {
|
|
82
|
+
let entries;
|
|
83
|
+
try {
|
|
84
|
+
entries = await fs.readdir(dir, { withFileTypes: true });
|
|
85
|
+
} catch {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
for (const entry of entries) {
|
|
89
|
+
const full = path.join(dir, entry.name);
|
|
90
|
+
if (entry.isDirectory()) {
|
|
91
|
+
await walk(full, extensions, acc);
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!entry.isFile()) continue;
|
|
95
|
+
// Skip previously emitted compressed artifacts so we never compress a .br
|
|
96
|
+
// into a .br.br on a subsequent run.
|
|
97
|
+
if (PRECOMPRESSED_SUFFIXES.some((s) => entry.name.endsWith(s))) continue;
|
|
98
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
99
|
+
if (extensions.has(ext)) acc.push(full);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export { precompressAssets };
|
|
@@ -92,6 +92,103 @@ async function discoverErrorPages(pagesDir) {
|
|
|
92
92
|
}
|
|
93
93
|
return result;
|
|
94
94
|
}
|
|
95
|
+
// Given a changed file path and an in-memory pages manifest, return the set of
|
|
96
|
+
// manifest entries whose server bundles must be rebuilt. Used by the lazy dev
|
|
97
|
+
// context (#250) so a watcher file-change event can mark only the affected
|
|
98
|
+
// pages/layouts/middlewares stale rather than forcing a full-graph rebuild.
|
|
99
|
+
//
|
|
100
|
+
// Cascade rules:
|
|
101
|
+
// - Page source edited → the one page whose `srcPath` matches.
|
|
102
|
+
// - Layout source edited → the layout itself plus every descendant page
|
|
103
|
+
// (any entry whose `layoutDirs` contains that layout's `dirPath`).
|
|
104
|
+
// - Middleware source edited → the middleware itself plus every descendant
|
|
105
|
+
// page (any entry whose `middlewareDirs` contains that middleware's
|
|
106
|
+
// `dirPath`).
|
|
107
|
+
// - Anything else → empty. Callers decide whether unrelated file changes
|
|
108
|
+
// warrant a full re-discovery (e.g. a new page file appeared).
|
|
109
|
+
//
|
|
110
|
+
// All path comparisons go through `path.resolve()` to normalize mixed
|
|
111
|
+
// absolute/relative inputs. `manifest.layouts` and `manifest.middlewares` may
|
|
112
|
+
// be supplied as Maps (the lazy context shape) or plain arrays of
|
|
113
|
+
// `{ dirPath, srcPath }` tuples — both are handled.
|
|
114
|
+
function mapFileToAffected(filePath, manifest) {
|
|
115
|
+
const result = { pages: [], layouts: [], middlewares: [] };
|
|
116
|
+
if (!filePath || !manifest) return result;
|
|
117
|
+
const target = path.resolve(filePath);
|
|
118
|
+
const entries = manifest.entries ?? [];
|
|
119
|
+
|
|
120
|
+
for (const entry of entries) {
|
|
121
|
+
if (entry.srcPath && path.resolve(entry.srcPath) === target) {
|
|
122
|
+
result.pages.push(entry.pattern);
|
|
123
|
+
return result;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const layoutEntries = asDirPathList(manifest.layouts);
|
|
128
|
+
const hitLayout = layoutEntries.find(
|
|
129
|
+
(l) => l.srcPath && path.resolve(l.srcPath) === target,
|
|
130
|
+
);
|
|
131
|
+
if (hitLayout) {
|
|
132
|
+
result.layouts.push(hitLayout.dirPath);
|
|
133
|
+
for (const entry of entries) {
|
|
134
|
+
if ((entry.layoutDirs ?? []).includes(hitLayout.dirPath)) {
|
|
135
|
+
result.pages.push(entry.pattern);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
return result;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const middlewareEntries = asDirPathList(manifest.middlewares);
|
|
142
|
+
const hitMw = middlewareEntries.find(
|
|
143
|
+
(m) => m.srcPath && path.resolve(m.srcPath) === target,
|
|
144
|
+
);
|
|
145
|
+
if (hitMw) {
|
|
146
|
+
result.middlewares.push(hitMw.dirPath);
|
|
147
|
+
for (const entry of entries) {
|
|
148
|
+
if ((entry.middlewareDirs ?? []).includes(hitMw.dirPath)) {
|
|
149
|
+
result.pages.push(entry.pattern);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return result;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return result;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Accept either Map<dirPath, { srcPath, … }> (lazy context shape) or a plain
|
|
159
|
+
// array of `{ dirPath, srcPath }` tuples (what `discoverLayouts` emits) so the
|
|
160
|
+
// helper works from either side of the refactor without adaptor code.
|
|
161
|
+
function asDirPathList(source) {
|
|
162
|
+
if (!source) return [];
|
|
163
|
+
if (Array.isArray(source)) return source;
|
|
164
|
+
if (source instanceof Map) {
|
|
165
|
+
return Array.from(source, ([dirPath, value]) => ({ dirPath, ...value }));
|
|
166
|
+
}
|
|
167
|
+
return [];
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Construct an in-memory manifest entry for a page. Shared between the
|
|
171
|
+
// lazy context's initial discovery pass and `rediscover()` so both code
|
|
172
|
+
// paths produce structurally identical entries (same chain resolution,
|
|
173
|
+
// same default flags). Resolves layout/middleware/loading dirs against
|
|
174
|
+
// the discovered chain lists rather than the in-memory manifest, which
|
|
175
|
+
// keeps the helper pure.
|
|
176
|
+
function buildPageManifestEntry(page, { layouts, middlewares, loadings }) {
|
|
177
|
+
return {
|
|
178
|
+
pattern: page.pattern,
|
|
179
|
+
paramNames: page.paramNames,
|
|
180
|
+
...(page.catchAllParam ? { catchAllParam: page.catchAllParam } : {}),
|
|
181
|
+
srcPath: path.resolve(page.filePath),
|
|
182
|
+
layoutDirs: resolveLayoutChain(page.pattern, layouts).map((l) => l.dirPath),
|
|
183
|
+
middlewareDirs: resolvePageMiddlewareChain(page.pattern, middlewares).map((m) => m.dirPath),
|
|
184
|
+
loadingDirs: resolveLoadingChain(page.pattern, loadings).map((l) => l.dirPath),
|
|
185
|
+
sharedChunks: [],
|
|
186
|
+
sharedCssChunks: [],
|
|
187
|
+
built: false,
|
|
188
|
+
stale: false,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
95
192
|
async function discoverStyles(pagesDir) {
|
|
96
193
|
const stylesPath = path.join(pagesDir, '_styles.css');
|
|
97
194
|
try {
|
|
@@ -107,12 +204,14 @@ function matchPage(pages, pathname) {
|
|
|
107
204
|
return { page: result.match, params: result.params };
|
|
108
205
|
}
|
|
109
206
|
export {
|
|
207
|
+
buildPageManifestEntry,
|
|
110
208
|
discoverErrorPages,
|
|
111
209
|
discoverLayouts,
|
|
112
210
|
discoverLoadings,
|
|
113
211
|
discoverPageMiddleware,
|
|
114
212
|
discoverPages,
|
|
115
213
|
discoverStyles,
|
|
214
|
+
mapFileToAffected,
|
|
116
215
|
matchPage,
|
|
117
216
|
resolveLayoutChain,
|
|
118
217
|
resolveLoadingChain,
|