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.
@@ -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 pagesDir. Tailwind's class-name emission depends
77
- // only on which tokens appear in scanned files — not on surrounding code
78
- // structure — so the digest captures the only source signal that actually
79
- // matters for CSS output. Editing handler logic, comments, or whitespace
80
- // leaves this digest unchanged and the CSS cache is reused.
81
- const classTokensDigest = await hashClassTokens(pagesDir);
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(pagesDir)}::class-tokens`, digest: classTokensDigest },
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
- async function buildServerBundles(pages, outDir, target, { rootDir, devMode, minify } = {}) {
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(async (page) => {
53
- const outName = patternToFileName(page.pattern);
54
- const outFile = path.join(serverDir, `${outName}.js`);
55
- await buildWithCache({
56
- rootDir: rootDir ?? outDir,
57
- kind: 'server',
58
- entryPath: page.filePath,
59
- outFile,
60
- esbuildOptions: jsxEsbuildOptions(page.filePath, outFile, target),
61
- esbuild,
62
- devMode,
63
- minify,
64
- });
65
- resultMap.set(page.pattern, path.relative(outDir, outFile).replace(/\\/g, '/'));
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(async (layout) => {
76
- const outName = layoutDirToFileName(layout.dirPath);
77
- const outFile = path.join(serverDir, `_layout_${outName}.js`);
78
- await buildWithCache({
79
- rootDir: rootDir ?? outDir,
80
- kind: 'layout',
81
- entryPath: layout.filePath,
82
- outFile,
83
- esbuildOptions: jsxEsbuildOptions(layout.filePath, outFile, target),
84
- esbuild,
85
- devMode,
86
- minify,
87
- });
88
- resultMap.set(layout.dirPath, path.relative(outDir, outFile).replace(/\\/g, '/'));
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(async (mw) => {
99
- const outName = layoutDirToFileName(mw.dirPath);
100
- const outFile = path.join(serverDir, `_middleware_${outName}.js`);
101
- await buildWithCache({
102
- rootDir: rootDir ?? outDir,
103
- kind: 'middleware',
104
- entryPath: mw.filePath,
105
- outFile,
106
- esbuildOptions: plainEsbuildOptions(mw.filePath, outFile, target),
107
- esbuild,
108
- devMode,
109
- minify,
110
- });
111
- resultMap.set(mw.dirPath, path.relative(outDir, outFile).replace(/\\/g, '/'));
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(async (build) => {
129
- const outFile = path.join(serverDir, `${build.outName}.js`);
130
- await buildWithCache({
131
- rootDir: rootDir ?? outDir,
132
- kind: 'errorPage',
133
- entryPath: build.filePath,
134
- outFile,
135
- esbuildOptions: jsxEsbuildOptions(build.filePath, outFile, target),
136
- esbuild,
137
- devMode,
138
- minify,
139
- });
140
- result[build.key] = path.relative(outDir, outFile);
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
  };
@@ -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: [], sharedChunks: [] } };
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,