arcway 0.1.14 → 0.1.15

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.14",
3
+ "version": "0.1.15",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -59,8 +59,8 @@ async function boot(options) {
59
59
  });
60
60
  await jobRunner.init();
61
61
 
62
- const fileWatcher = new FileWatcher(rootDir, { log });
63
- await fileWatcher.start();
62
+ const fileWatcher = mode === 'development' ? new FileWatcher(rootDir, { log }) : null;
63
+ if (fileWatcher) await fileWatcher.start();
64
64
 
65
65
  const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher };
66
66
 
@@ -68,13 +68,14 @@ async function boot(options) {
68
68
 
69
69
  const apiRouter = new ApiRouter(config.api, {
70
70
  log,
71
+ mode,
71
72
  fileWatcher,
72
73
  appContext,
73
74
  sessionConfig: config.session,
74
75
  });
75
76
  await apiRouter.init();
76
77
 
77
- const pagesRouter = new PagesRouter(config, { rootDir, log, fileWatcher, appContext });
78
+ const pagesRouter = new PagesRouter(config, { rootDir, log, mode, fileWatcher, appContext });
78
79
  await pagesRouter.init();
79
80
 
80
81
  const healthDeps = {
@@ -117,7 +118,7 @@ async function boot(options) {
117
118
  await pagesRouter.close();
118
119
  await apiRouter.close();
119
120
  await webServer.close();
120
- await fileWatcher.close();
121
+ if (fileWatcher) await fileWatcher.close();
121
122
  await destroyInfrastructure(infrastructure);
122
123
  await mcpRouter.cleanup(rootDir);
123
124
  };
@@ -194,10 +194,81 @@ async function buildWithCache({
194
194
  return { cacheHit: false, metafile: result.metafile };
195
195
  }
196
196
 
197
+ // Multi-file cache for bundles with several output files (client build with
198
+ // code-splitting). Each cache entry lives under <bucket>/<coarseKey>/ and the
199
+ // index is a sidecar <bucket>/<coarseKey>.meta.json.
200
+ async function lookupMultiFileCache({ rootDir, kind, coarseKey }) {
201
+ const bucket = bucketDir(rootDir, kind);
202
+ const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
203
+ const cacheDir = path.join(bucket, coarseKey);
204
+ const meta = await readJson(metaPath);
205
+ if (!meta) return { hit: false, bucket, cacheDir, metaPath };
206
+ const currentHash = await hashInputs(meta.inputs);
207
+ if (currentHash === null || currentHash !== meta.inputsHash) {
208
+ return { hit: false, bucket, cacheDir, metaPath };
209
+ }
210
+ // Sanity-check every recorded output file still exists in the cache dir.
211
+ for (const rel of meta.outputs) {
212
+ try {
213
+ await fs.access(path.join(cacheDir, rel));
214
+ } catch {
215
+ return { hit: false, bucket, cacheDir, metaPath };
216
+ }
217
+ }
218
+ return { hit: true, bucket, cacheDir, metaPath, meta };
219
+ }
220
+
221
+ async function restoreMultiFileCache({ cacheDir, outputs, destDir }) {
222
+ await fs.mkdir(destDir, { recursive: true });
223
+ await Promise.all(
224
+ outputs.map(async (rel) => {
225
+ const src = path.join(cacheDir, rel);
226
+ const dst = path.join(destDir, rel);
227
+ await fs.mkdir(path.dirname(dst), { recursive: true });
228
+ await fs.cp(src, dst);
229
+ }),
230
+ );
231
+ }
232
+
233
+ async function storeMultiFileCache({
234
+ bucket,
235
+ coarseKey,
236
+ destDir,
237
+ outputs,
238
+ inputs,
239
+ metadata,
240
+ }) {
241
+ const cacheDir = path.join(bucket, coarseKey);
242
+ await fs.rm(cacheDir, { recursive: true, force: true });
243
+ await fs.mkdir(cacheDir, { recursive: true });
244
+ await Promise.all(
245
+ outputs.map(async (rel) => {
246
+ const src = path.join(destDir, rel);
247
+ const dst = path.join(cacheDir, rel);
248
+ await fs.mkdir(path.dirname(dst), { recursive: true });
249
+ await cpAtomic(src, dst);
250
+ }),
251
+ );
252
+ const inputsHash = await hashInputs(inputs);
253
+ const metaPath = path.join(bucket, `${coarseKey}.meta.json`);
254
+ await writeJsonAtomic(metaPath, {
255
+ inputs,
256
+ inputsHash,
257
+ outputs,
258
+ metadata,
259
+ mtime: Date.now(),
260
+ });
261
+ }
262
+
197
263
  export {
198
264
  buildWithCache,
199
265
  computeConfigHash,
200
266
  cacheRoot,
201
267
  entryKey,
202
268
  ESBUILD_VERSION,
269
+ sha256,
270
+ hashInputs,
271
+ lookupMultiFileCache,
272
+ restoreMultiFileCache,
273
+ storeMultiFileCache,
203
274
  };
@@ -6,73 +6,137 @@ import { resolveLayoutChain, resolveLoadingChain } from './discovery.js';
6
6
  import { clientIsolationPlugin } from './build-plugins.js';
7
7
  import { patternToFileName, layoutDirToFileName } from './build-server.js';
8
8
  import { reactRefreshPlugin } from './hmr.js';
9
- async function buildClientBundles(
10
- pages,
11
- layouts,
12
- outDir,
13
- target,
14
- minify,
15
- rootDir,
16
- loadings = [],
17
- nodeEnv = 'production',
18
- devMode = false,
19
- ) {
20
- const clientDir = path.join(outDir, 'client');
21
- const bundles = new Map();
22
- const navBundles = new Map();
23
- const layoutNavBundles = new Map();
24
- const loadingNavBundles = new Map();
9
+ import {
10
+ sha256,
11
+ ESBUILD_VERSION,
12
+ lookupMultiFileCache,
13
+ restoreMultiFileCache,
14
+ storeMultiFileCache,
15
+ } from './build-cache.js';
16
+
17
+ // Build a deterministic plan of the hydration entries that will be written
18
+ // into the staging dir. Computed up-front so we can derive a cache key
19
+ // before running esbuild.
20
+ function planHydrationEntries(pages, layouts, loadings, tempDir) {
21
+ const files = [];
25
22
  const entryPoints = {};
26
- const tempDir = path.join(outDir, '.hydration-entries');
27
- await fs.mkdir(tempDir, { recursive: true });
23
+ const entryToPattern = new Map();
24
+ const entryToNavPattern = new Map();
25
+ const entryToLayoutDir = new Map();
26
+ const entryToLoadingDir = new Map();
27
+
28
28
  for (const page of pages) {
29
29
  const outName = patternToFileName(page.pattern);
30
30
  const layoutChain = resolveLayoutChain(page.pattern, layouts);
31
31
  const loadingChain = resolveLoadingChain(page.pattern, loadings);
32
- const entryContent = generateHydrationEntry(page.filePath, layoutChain, loadingChain);
32
+ const content = generateHydrationEntry(page.filePath, layoutChain, loadingChain);
33
33
  const entryPath = path.join(tempDir, `${outName}.tsx`);
34
- await fs.mkdir(path.dirname(entryPath), { recursive: true });
35
- await fs.writeFile(entryPath, entryContent);
34
+ files.push({ entryPath, content });
36
35
  entryPoints[outName] = entryPath;
36
+ entryToPattern.set(entryPath, page.pattern);
37
37
  }
38
38
  for (const page of pages) {
39
39
  const navName = `nav_${patternToFileName(page.pattern)}`;
40
40
  const importPath = page.filePath.replace(/\\/g, '/');
41
- const navContent = `export { default } from '${importPath}';
42
- `;
41
+ const content = `export { default } from '${importPath}';\n`;
43
42
  const navPath = path.join(tempDir, `${navName}.tsx`);
44
- await fs.mkdir(path.dirname(navPath), { recursive: true });
45
- await fs.writeFile(navPath, navContent);
43
+ files.push({ entryPath: navPath, content });
46
44
  entryPoints[navName] = navPath;
45
+ entryToNavPattern.set(navPath, page.pattern);
47
46
  }
48
47
  for (const layout of layouts) {
49
48
  const layoutName = `nav_layout_${layoutDirToFileName(layout.dirPath)}`;
50
49
  const importPath = layout.filePath.replace(/\\/g, '/');
51
- const navContent = `export { default } from '${importPath}';
52
- `;
50
+ const content = `export { default } from '${importPath}';\n`;
53
51
  const navPath = path.join(tempDir, `${layoutName}.tsx`);
54
- await fs.mkdir(path.dirname(navPath), { recursive: true });
55
- await fs.writeFile(navPath, navContent);
52
+ files.push({ entryPath: navPath, content });
56
53
  entryPoints[layoutName] = navPath;
54
+ entryToLayoutDir.set(navPath, layout.dirPath);
57
55
  }
58
56
  for (const loading of loadings) {
59
57
  const loadingName = `nav_loading_${layoutDirToFileName(loading.dirPath)}`;
60
58
  const importPath = loading.filePath.replace(/\\/g, '/');
61
- const navContent = `export { default } from '${importPath}';
62
- `;
59
+ const content = `export { default } from '${importPath}';\n`;
63
60
  const navPath = path.join(tempDir, `${loadingName}.tsx`);
64
- await fs.mkdir(path.dirname(navPath), { recursive: true });
65
- await fs.writeFile(navPath, navContent);
61
+ files.push({ entryPath: navPath, content });
66
62
  entryPoints[loadingName] = navPath;
63
+ entryToLoadingDir.set(navPath, loading.dirPath);
67
64
  }
65
+ return { files, entryPoints, entryToPattern, entryToNavPattern, entryToLayoutDir, entryToLoadingDir };
66
+ }
67
+
68
+ function resolveReactAlias(rootDir) {
68
69
  const projectRequire = createRequire(path.join(rootDir, 'package.json'));
69
- const reactAlias = {};
70
+ const alias = {};
70
71
  for (const pkg of ['react', 'react-dom']) {
71
72
  try {
72
- reactAlias[pkg] = path.dirname(projectRequire.resolve(`${pkg}/package.json`));
73
+ alias[pkg] = path.dirname(projectRequire.resolve(`${pkg}/package.json`));
73
74
  } catch {}
74
75
  }
75
- const result = await esbuild.build({
76
+ return alias;
77
+ }
78
+
79
+ // Cache key is derived from everything that could change the client bundle
80
+ // output set except for the actual source-file contents (those are tracked
81
+ // as the invalidation inputs list and re-hashed on lookup).
82
+ function computeClientCoarseKey({
83
+ target,
84
+ minify,
85
+ devMode,
86
+ nodeEnv,
87
+ reactAlias,
88
+ pages,
89
+ layouts,
90
+ loadings,
91
+ hydrationContents,
92
+ }) {
93
+ return sha256(
94
+ JSON.stringify({
95
+ esbuildVersion: ESBUILD_VERSION,
96
+ target: target ?? '',
97
+ minify: !!minify,
98
+ devMode: !!devMode,
99
+ nodeEnv: nodeEnv ?? '',
100
+ reactAlias,
101
+ pages: pages.map((p) => ({ pattern: p.pattern, filePath: path.resolve(p.filePath) })),
102
+ layouts: layouts.map((l) => ({ dirPath: l.dirPath, filePath: path.resolve(l.filePath) })),
103
+ loadings: loadings.map((l) => ({ dirPath: l.dirPath, filePath: path.resolve(l.filePath) })),
104
+ hydrationContents,
105
+ }),
106
+ );
107
+ }
108
+
109
+ function serializeMetadata(meta) {
110
+ return {
111
+ bundles: [...meta.bundles.entries()],
112
+ navBundles: [...meta.navBundles.entries()],
113
+ layoutNavBundles: [...meta.layoutNavBundles.entries()],
114
+ loadingNavBundles: [...meta.loadingNavBundles.entries()],
115
+ sharedChunks: meta.sharedChunks,
116
+ };
117
+ }
118
+
119
+ function deserializeMetadata(raw) {
120
+ return {
121
+ bundles: new Map(raw.bundles),
122
+ navBundles: new Map(raw.navBundles),
123
+ layoutNavBundles: new Map(raw.layoutNavBundles),
124
+ loadingNavBundles: new Map(raw.loadingNavBundles),
125
+ sharedChunks: raw.sharedChunks,
126
+ };
127
+ }
128
+
129
+ function buildClientEsbuildOptions({
130
+ entryPoints,
131
+ clientDir,
132
+ target,
133
+ minify,
134
+ devMode,
135
+ nodeEnv,
136
+ reactAlias,
137
+ rootDir,
138
+ }) {
139
+ return {
76
140
  entryPoints,
77
141
  outdir: clientDir,
78
142
  format: 'esm',
@@ -90,25 +154,174 @@ async function buildClientBundles(
90
154
  alias: reactAlias,
91
155
  define: { 'process.env.NODE_ENV': JSON.stringify(nodeEnv) },
92
156
  plugins: [...(devMode ? [reactRefreshPlugin(rootDir)] : []), clientIsolationPlugin()],
93
- });
94
- await fs.rm(tempDir, { recursive: true, force: true });
95
- const entryToPattern = new Map();
96
- const entryToNavPattern = new Map();
97
- const entryToLayoutDir = new Map();
98
- for (const page of pages) {
99
- const outName = patternToFileName(page.pattern);
100
- entryToPattern.set(path.join(tempDir, `${outName}.tsx`), page.pattern);
101
- entryToNavPattern.set(path.join(tempDir, `nav_${outName}.tsx`), page.pattern);
157
+ };
158
+ }
159
+
160
+ async function writeHydrationEntries(plan) {
161
+ await Promise.all(
162
+ plan.files.map(async (f) => {
163
+ await fs.mkdir(path.dirname(f.entryPath), { recursive: true });
164
+ await fs.writeFile(f.entryPath, f.content);
165
+ }),
166
+ );
167
+ }
168
+
169
+ // Long-lived esbuild context for the client bundle. Used by the dev watcher so
170
+ // file-change rebuilds call `ctx.rebuild()` (incremental) instead of spinning up
171
+ // a fresh esbuild per change. The context is bound to the specific
172
+ // pages/layouts/loadings set; structural changes (add/remove page) require
173
+ // disposing and recreating.
174
+ async function createClientBuildContext(
175
+ pages,
176
+ layouts,
177
+ outDir,
178
+ target,
179
+ minify,
180
+ rootDir,
181
+ loadings = [],
182
+ nodeEnv = 'production',
183
+ devMode = false,
184
+ ) {
185
+ const clientDir = path.join(outDir, 'client');
186
+ const tempDir = path.join(outDir, '.hydration-entries');
187
+ const plan = planHydrationEntries(pages, layouts, loadings, tempDir);
188
+ const reactAlias = resolveReactAlias(rootDir);
189
+
190
+ await fs.mkdir(clientDir, { recursive: true });
191
+ await fs.mkdir(tempDir, { recursive: true });
192
+ await writeHydrationEntries(plan);
193
+
194
+ const ctx = await esbuild.context(
195
+ buildClientEsbuildOptions({
196
+ entryPoints: plan.entryPoints,
197
+ clientDir,
198
+ target,
199
+ minify,
200
+ devMode,
201
+ nodeEnv,
202
+ reactAlias,
203
+ rootDir,
204
+ }),
205
+ );
206
+
207
+ let disposed = false;
208
+
209
+ async function rebuild() {
210
+ const result = await ctx.rebuild();
211
+ const metadata = extractMetadata(result, plan, outDir);
212
+ return { ...metadata, metafile: result.metafile };
102
213
  }
103
- for (const layout of layouts) {
104
- const layoutName = `nav_layout_${layoutDirToFileName(layout.dirPath)}`;
105
- entryToLayoutDir.set(path.join(tempDir, `${layoutName}.tsx`), layout.dirPath);
214
+
215
+ async function dispose() {
216
+ if (disposed) return;
217
+ disposed = true;
218
+ await ctx.dispose();
219
+ await fs.rm(tempDir, { recursive: true, force: true });
106
220
  }
107
- const entryToLoadingDir = new Map();
108
- for (const loading of loadings) {
109
- const loadingName = `nav_loading_${layoutDirToFileName(loading.dirPath)}`;
110
- entryToLoadingDir.set(path.join(tempDir, `${loadingName}.tsx`), loading.dirPath);
221
+
222
+ return { rebuild, dispose };
223
+ }
224
+
225
+ async function buildClientBundles(
226
+ pages,
227
+ layouts,
228
+ outDir,
229
+ target,
230
+ minify,
231
+ rootDir,
232
+ loadings = [],
233
+ nodeEnv = 'production',
234
+ devMode = false,
235
+ ) {
236
+ const clientDir = path.join(outDir, 'client');
237
+ const tempDir = path.join(outDir, '.hydration-entries');
238
+ const plan = planHydrationEntries(pages, layouts, loadings, tempDir);
239
+ const reactAlias = resolveReactAlias(rootDir);
240
+
241
+ // Cache is keyed on the full build intent (including exact hydration-entry
242
+ // contents). devMode skips the cache because the watcher will eventually own
243
+ // incremental rebuilds via esbuild.context() (Phase 4) and we also return
244
+ // the metafile directly in dev mode for HMR diffing.
245
+ const useCache = !devMode;
246
+ let cacheBucket;
247
+ let coarseKey;
248
+ if (useCache) {
249
+ coarseKey = computeClientCoarseKey({
250
+ target,
251
+ minify,
252
+ devMode,
253
+ nodeEnv,
254
+ reactAlias,
255
+ pages,
256
+ layouts,
257
+ loadings,
258
+ hydrationContents: plan.files.map((f) => ({
259
+ name: path.basename(f.entryPath),
260
+ content: f.content,
261
+ })),
262
+ });
263
+ const lookup = await lookupMultiFileCache({ rootDir, kind: 'client', coarseKey });
264
+ cacheBucket = lookup.bucket;
265
+ if (lookup.hit) {
266
+ await restoreMultiFileCache({
267
+ cacheDir: lookup.cacheDir,
268
+ outputs: lookup.meta.outputs,
269
+ destDir: clientDir,
270
+ });
271
+ return deserializeMetadata(lookup.meta.metadata);
272
+ }
273
+ }
274
+
275
+ await fs.mkdir(tempDir, { recursive: true });
276
+ await writeHydrationEntries(plan);
277
+
278
+ const result = await esbuild.build(
279
+ buildClientEsbuildOptions({
280
+ entryPoints: plan.entryPoints,
281
+ clientDir,
282
+ target,
283
+ minify,
284
+ devMode,
285
+ nodeEnv,
286
+ reactAlias,
287
+ rootDir,
288
+ }),
289
+ );
290
+ await fs.rm(tempDir, { recursive: true, force: true });
291
+
292
+ const metadata = extractMetadata(result, plan, outDir);
293
+
294
+ if (useCache) {
295
+ const outputs = Object.keys(result.metafile.outputs)
296
+ .map((p) => path.relative(clientDir, path.resolve(p)).replace(/\\/g, '/'))
297
+ .filter((rel) => !rel.startsWith('..'));
298
+ // Track only inputs on the real filesystem — the ephemeral hydration entries
299
+ // inside `tempDir` are re-generated each build from a stable content plan,
300
+ // so their paths change every run and can't stably invalidate the cache.
301
+ const inputs = Object.keys(result.metafile.inputs)
302
+ .map((p) => (path.isAbsolute(p) ? p : path.resolve(p)))
303
+ .filter((p) => !p.startsWith(tempDir));
304
+ await storeMultiFileCache({
305
+ bucket: cacheBucket,
306
+ coarseKey,
307
+ destDir: clientDir,
308
+ outputs,
309
+ inputs,
310
+ metadata: serializeMetadata(metadata),
311
+ });
111
312
  }
313
+
314
+ return {
315
+ ...metadata,
316
+ ...(devMode ? { metafile: result.metafile } : {}),
317
+ };
318
+ }
319
+
320
+ function extractMetadata(result, plan, outDir) {
321
+ const bundles = new Map();
322
+ const navBundles = new Map();
323
+ const layoutNavBundles = new Map();
324
+ const loadingNavBundles = new Map();
112
325
  const sharedChunks = [];
113
326
  for (const [outputPath, meta] of Object.entries(result.metafile.outputs)) {
114
327
  const relPath = path.relative(outDir, outputPath).replace(/\\/g, '/');
@@ -119,41 +332,24 @@ async function buildClientBundles(
119
332
  }
120
333
  if (meta.entryPoint) {
121
334
  const absEntry = path.resolve(meta.entryPoint);
122
- for (const [entryPath, pattern] of entryToPattern) {
123
- if (absEntry === entryPath) {
124
- bundles.set(pattern, relPath);
125
- break;
126
- }
127
- }
128
- for (const [entryPath, pattern] of entryToNavPattern) {
129
- if (absEntry === entryPath) {
130
- navBundles.set(pattern, relPath);
131
- break;
132
- }
133
- }
134
- for (const [entryPath, dirPath] of entryToLayoutDir) {
135
- if (absEntry === entryPath) {
136
- layoutNavBundles.set(dirPath, relPath);
137
- break;
138
- }
139
- }
140
- for (const [entryPath, dirPath] of entryToLoadingDir) {
141
- if (absEntry === entryPath) {
142
- loadingNavBundles.set(dirPath, relPath);
143
- break;
144
- }
335
+ const pattern =
336
+ plan.entryToPattern.get(absEntry) ?? plan.entryToNavPattern.get(absEntry);
337
+ if (plan.entryToPattern.has(absEntry)) {
338
+ bundles.set(plan.entryToPattern.get(absEntry), relPath);
339
+ } else if (plan.entryToNavPattern.has(absEntry)) {
340
+ navBundles.set(plan.entryToNavPattern.get(absEntry), relPath);
341
+ } else if (plan.entryToLayoutDir.has(absEntry)) {
342
+ layoutNavBundles.set(plan.entryToLayoutDir.get(absEntry), relPath);
343
+ } else if (plan.entryToLoadingDir.has(absEntry)) {
344
+ loadingNavBundles.set(plan.entryToLoadingDir.get(absEntry), relPath);
145
345
  }
346
+ // pattern variable intentionally unused when none of the maps match
347
+ void pattern;
146
348
  }
147
349
  }
148
- return {
149
- bundles,
150
- navBundles,
151
- layoutNavBundles,
152
- loadingNavBundles,
153
- sharedChunks,
154
- ...(devMode ? { metafile: result.metafile } : {}),
155
- };
350
+ return { bundles, navBundles, layoutNavBundles, loadingNavBundles, sharedChunks };
156
351
  }
352
+
157
353
  function generateHydrationEntry(componentPath, layouts = [], loadings = []) {
158
354
  const importPath = componentPath.replace(/\\/g, '/');
159
355
  const imports = [
@@ -184,4 +380,4 @@ const props = propsEl ? JSON.parse(propsEl.textContent || '{}') : {};
184
380
  hydrateRoot(container!, <ApiProvider><Router initialPath={window.location.pathname} initialParams={props} initialComponent={Component} initialLayouts={${layoutsArray}} initialLoadings={${loadingsArray}} /></ApiProvider>);
185
381
  `;
186
382
  }
187
- export { buildClientBundles, generateHydrationEntry };
383
+ export { buildClientBundles, createClientBuildContext, generateHydrationEntry };
@@ -0,0 +1,172 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import {
4
+ discoverPages,
5
+ discoverLayouts,
6
+ discoverLoadings,
7
+ discoverPageMiddleware,
8
+ discoverErrorPages,
9
+ discoverStyles,
10
+ } from './discovery.js';
11
+ import {
12
+ buildServerBundles,
13
+ buildLayoutServerBundles,
14
+ buildMiddlewareServerBundles,
15
+ buildErrorPageBundles,
16
+ } from './build-server.js';
17
+ import { createClientBuildContext } from './build-client.js';
18
+ import { buildCssBundle } from './build-css.js';
19
+ import { generateManifest } from './build-manifest.js';
20
+ import { resolveFonts } from './fonts.js';
21
+ import { buildHmrRuntimeBundle } from './hmr.js';
22
+
23
+ // Long-lived build context for dev-watch mode. Unlike `buildPages()`, which
24
+ // rebuilds every esbuild graph from scratch, this keeps an incremental
25
+ // `esbuild.context()` alive for the client bundle so file-change rebuilds are
26
+ // near-instant. Server, layout, middleware, CSS, and error-page bundles reuse
27
+ // the persistent on-disk cache (phase 2/3), which already handles per-entry
28
+ // invalidation cheaply. The dev outDir is written in place — the atomic staging
29
+ // swap is a prod-safety feature that conflicts with esbuild's requirement for a
30
+ // stable outdir across rebuilds.
31
+ async function createPagesBuildContext(options) {
32
+ const { rootDir } = options;
33
+ const pagesDir = options.pagesDir ?? path.join(rootDir, 'pages');
34
+ const outDir = path.resolve(rootDir, options.outDir ?? '.build/pages');
35
+ const serverTarget = options.serverTarget ?? 'node22';
36
+ const clientTarget = options.clientTarget ?? 'es2022';
37
+ const minify = options.minify ?? false;
38
+ const devMode = options.devMode ?? true;
39
+
40
+ let fontFaceCss;
41
+ let fontPreloadHtml;
42
+ if (options.fonts && options.fonts.length > 0) {
43
+ const publicDir = path.join(rootDir, 'public');
44
+ const fontResult = await resolveFonts(options.fonts, publicDir);
45
+ if (fontResult.fontFaceCss) fontFaceCss = fontResult.fontFaceCss;
46
+ if (fontResult.preloadHtml) fontPreloadHtml = fontResult.preloadHtml;
47
+ }
48
+
49
+ let clientCtx = null;
50
+ let lastStructuralKey = null;
51
+ let lastResult = null;
52
+ let disposed = false;
53
+
54
+ async function ensureClientContext(pages, layouts, loadings) {
55
+ const structuralKey = computeStructuralKey(pages, layouts, loadings);
56
+ if (clientCtx !== null && structuralKey === lastStructuralKey) return;
57
+ if (clientCtx) {
58
+ await clientCtx.dispose();
59
+ clientCtx = null;
60
+ }
61
+ clientCtx = await createClientBuildContext(
62
+ pages,
63
+ layouts,
64
+ outDir,
65
+ clientTarget,
66
+ minify,
67
+ rootDir,
68
+ loadings,
69
+ minify ? 'production' : 'development',
70
+ devMode,
71
+ );
72
+ lastStructuralKey = structuralKey;
73
+ }
74
+
75
+ async function rebuild() {
76
+ if (disposed) throw new Error('createPagesBuildContext: context has been disposed');
77
+
78
+ const [pages, layouts, loadings, middlewares, errorPages, stylesPath] = await Promise.all([
79
+ discoverPages(pagesDir),
80
+ discoverLayouts(pagesDir),
81
+ discoverLoadings(pagesDir),
82
+ discoverPageMiddleware(pagesDir),
83
+ discoverErrorPages(pagesDir),
84
+ discoverStyles(pagesDir),
85
+ ]);
86
+
87
+ if (pages.length === 0) {
88
+ lastResult = { pageCount: 0, outDir, manifest: { entries: [], sharedChunks: [] } };
89
+ return lastResult;
90
+ }
91
+
92
+ await fs.mkdir(path.join(outDir, 'server'), { recursive: true });
93
+ await fs.mkdir(path.join(outDir, 'client'), { recursive: true });
94
+
95
+ await ensureClientContext(pages, layouts, loadings);
96
+
97
+ const serverCacheOpts = { rootDir, devMode, minify };
98
+ const builds = [
99
+ buildServerBundles(pages, outDir, serverTarget, serverCacheOpts),
100
+ buildLayoutServerBundles(layouts, outDir, serverTarget, serverCacheOpts),
101
+ buildMiddlewareServerBundles(middlewares, outDir, serverTarget, serverCacheOpts),
102
+ clientCtx.rebuild(),
103
+ buildErrorPageBundles(errorPages, outDir, serverTarget, serverCacheOpts),
104
+ buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir),
105
+ ];
106
+ if (devMode) builds.push(buildHmrRuntimeBundle(outDir));
107
+
108
+ const [
109
+ serverResult,
110
+ layoutServerResult,
111
+ middlewareServerResult,
112
+ clientResult,
113
+ errorBundles,
114
+ cssBundle,
115
+ ] = await Promise.all(builds);
116
+
117
+ const manifest = generateManifest(
118
+ pages,
119
+ layouts,
120
+ loadings,
121
+ middlewares,
122
+ serverResult,
123
+ layoutServerResult,
124
+ middlewareServerResult,
125
+ clientResult,
126
+ );
127
+ if (errorBundles.error) manifest.errorBundle = errorBundles.error;
128
+ if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
129
+ if (cssBundle) manifest.cssBundle = cssBundle;
130
+ if (fontPreloadHtml) manifest.fontPreloadHtml = fontPreloadHtml;
131
+
132
+ await fs.writeFile(
133
+ path.join(outDir, 'pages-manifest.json'),
134
+ JSON.stringify(manifest, null, 2),
135
+ );
136
+
137
+ lastResult = {
138
+ pageCount: pages.length,
139
+ outDir,
140
+ manifest,
141
+ ...(clientResult.metafile ? { clientMetafile: clientResult.metafile } : {}),
142
+ };
143
+ return lastResult;
144
+ }
145
+
146
+ async function dispose() {
147
+ if (disposed) return;
148
+ disposed = true;
149
+ if (clientCtx) {
150
+ await clientCtx.dispose();
151
+ clientCtx = null;
152
+ }
153
+ }
154
+
155
+ return {
156
+ rebuild,
157
+ dispose,
158
+ get currentResult() {
159
+ return lastResult;
160
+ },
161
+ };
162
+ }
163
+
164
+ function computeStructuralKey(pages, layouts, loadings) {
165
+ return JSON.stringify({
166
+ p: pages.map((p) => p.pattern).sort(),
167
+ l: layouts.map((l) => l.dirPath).sort(),
168
+ ld: loadings.map((l) => l.dirPath).sort(),
169
+ });
170
+ }
171
+
172
+ export { createPagesBuildContext };
@@ -5,13 +5,80 @@ import { createRequire } from 'node:module';
5
5
  import esbuild from 'esbuild';
6
6
  import postcss from 'postcss';
7
7
  import tailwindPostcss from '@tailwindcss/postcss';
8
- async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss) {
8
+ import {
9
+ sha256,
10
+ lookupMultiFileCache,
11
+ restoreMultiFileCache,
12
+ storeMultiFileCache,
13
+ } from './build-cache.js';
14
+
15
+ // Shallow-walk the pages dir and return every source-shaped file. Used as the
16
+ // cache-invalidation input set when tailwind will scan it via @source.
17
+ async function collectTailwindSources(pagesDir) {
18
+ const out = [];
19
+ async function walk(dir) {
20
+ let entries;
21
+ try {
22
+ entries = await fs.readdir(dir, { withFileTypes: true });
23
+ } catch {
24
+ return;
25
+ }
26
+ for (const e of entries) {
27
+ const full = path.join(dir, e.name);
28
+ if (e.isDirectory()) {
29
+ if (e.name === 'node_modules' || e.name.startsWith('.')) continue;
30
+ await walk(full);
31
+ } else if (e.isFile()) {
32
+ if (/\.(jsx?|tsx?|mjs|cjs|css)$/i.test(e.name)) {
33
+ out.push(full);
34
+ }
35
+ }
36
+ }
37
+ }
38
+ await walk(pagesDir);
39
+ return out;
40
+ }
41
+
42
+ function computeCssCoarseKey({ minify, fontFaceCss, stylesPath }) {
43
+ return sha256(
44
+ JSON.stringify({
45
+ minify: !!minify,
46
+ fontFaceCss: fontFaceCss ?? '',
47
+ stylesPath: stylesPath ? path.resolve(stylesPath) : null,
48
+ }),
49
+ );
50
+ }
51
+
52
+ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir) {
9
53
  try {
10
54
  await fs.access(pagesDir);
11
55
  } catch {
12
- // Pages directory doesn't exist — no CSS to build
13
56
  return void 0;
14
57
  }
58
+
59
+ const clientDir = path.join(outDir, 'client');
60
+ rootDir = rootDir ?? path.dirname(outDir);
61
+ const coarseKey = computeCssCoarseKey({ minify, fontFaceCss, stylesPath });
62
+
63
+ // Inputs that should invalidate the cache on content change.
64
+ const inputs = [];
65
+ if (stylesPath) {
66
+ inputs.push(path.resolve(stylesPath));
67
+ } else {
68
+ const scanned = await collectTailwindSources(pagesDir);
69
+ for (const f of scanned) inputs.push(f);
70
+ }
71
+
72
+ const lookup = await lookupMultiFileCache({ rootDir, kind: 'css', coarseKey });
73
+ if (lookup.hit) {
74
+ await restoreMultiFileCache({
75
+ cacheDir: lookup.cacheDir,
76
+ outputs: lookup.meta.outputs,
77
+ destDir: clientDir,
78
+ });
79
+ return lookup.meta.metadata.cssBundleRel;
80
+ }
81
+
15
82
  let cssSource;
16
83
  if (stylesPath) {
17
84
  cssSource = `@import ${JSON.stringify(stylesPath)};`;
@@ -29,19 +96,28 @@ async function buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss)
29
96
  from: stylesPath ?? path.join(pagesDir, '__virtual_entry.css'),
30
97
  to: path.join(outDir, 'client', 'styles.css'),
31
98
  });
32
- let css = fontFaceCss
33
- ? `${fontFaceCss}
34
-
35
- ${result.css}`
36
- : result.css;
99
+ let css = fontFaceCss ? `${fontFaceCss}\n\n${result.css}` : result.css;
37
100
  if (minify) {
38
101
  const minified = await esbuild.transform(css, { loader: 'css', minify: true });
39
102
  css = minified.code;
40
103
  }
41
104
  const hash = crypto.createHash('sha256').update(css).digest('hex').slice(0, 8);
42
105
  const cssFileName = `styles-${hash}.css`;
43
- const outFile = path.join(outDir, 'client', cssFileName);
106
+ const outFile = path.join(clientDir, cssFileName);
107
+ await fs.mkdir(clientDir, { recursive: true });
44
108
  await fs.writeFile(outFile, css);
45
- return `client/${cssFileName}`;
109
+ const cssBundleRel = `client/${cssFileName}`;
110
+
111
+ await storeMultiFileCache({
112
+ bucket: lookup.bucket,
113
+ coarseKey,
114
+ destDir: clientDir,
115
+ outputs: [cssFileName],
116
+ inputs,
117
+ metadata: { cssBundleRel },
118
+ });
119
+
120
+ return cssBundleRel;
46
121
  }
122
+
47
123
  export { buildCssBundle };
@@ -74,7 +74,7 @@ async function buildPages(options) {
74
74
  devMode,
75
75
  ),
76
76
  buildErrorPageBundles(errorPages, buildDir, serverTarget, serverCacheOpts),
77
- buildCssBundle(pagesDir, buildDir, minify, stylesPath, fontFaceCss),
77
+ buildCssBundle(pagesDir, buildDir, minify, stylesPath, fontFaceCss, rootDir),
78
78
  );
79
79
  if (devMode) builds.push(buildHmrRuntimeBundle(buildDir));
80
80
  // Fail fast: `Promise.all` rejects the moment any build throws, so a broken
@@ -123,13 +123,24 @@ async function buildPages(options) {
123
123
  // before removing the directory — otherwise `fs.rm` races the writes and
124
124
  // fails with ENOTEMPTY. The cleanup is fire-and-forget so the caller sees
125
125
  // the original error immediately; the unique staging-dir suffix means any
126
- // lingering files can't pollute a subsequent `buildPages` call.
127
- void Promise.allSettled(builds).then(() =>
126
+ // lingering files can't pollute a subsequent `buildPages` call. Pending
127
+ // cleanups are tracked so tests can await quiescence.
128
+ const cleanup = Promise.allSettled(builds).then(() =>
128
129
  fs.rm(buildDir, { recursive: true, force: true }).catch(() => {}),
129
130
  );
131
+ pendingCleanups.add(cleanup);
132
+ cleanup.finally(() => pendingCleanups.delete(cleanup));
130
133
  throw err;
131
134
  }
132
135
  }
136
+ // Tracks fire-and-forget cleanup promises from failed builds so tests can
137
+ // await `buildPagesIdle()` and see a clean `.build` dir.
138
+ const pendingCleanups = new Set();
139
+ async function buildPagesIdle() {
140
+ while (pendingCleanups.size > 0) {
141
+ await Promise.allSettled([...pendingCleanups]);
142
+ }
143
+ }
133
144
  // Serialize concurrent swaps targeting the same outDir. Without this, two
134
145
  // parallel `buildPages` calls against the same outDir race between the
135
146
  // `fs.rm(outDir)` and `fs.rename(buildDir, outDir)` steps: both rm's succeed,
@@ -161,4 +172,4 @@ function rewriteMetafilePaths(metafile, fromDir, toDir) {
161
172
  return { ...metafile, outputs };
162
173
  }
163
174
  import { patternToFileName } from './build-server.js';
164
- export { buildPages, patternToFileName };
175
+ export { buildPages, patternToFileName, buildPagesIdle };
@@ -19,7 +19,8 @@ function createPagesHandler(options) {
19
19
  const manifestPath = path.join(outDir, 'pages-manifest.json');
20
20
  const sessionConfig = options.session;
21
21
  const appContext = options.appContext ?? null;
22
- const devMode = options.devMode ?? false;
22
+ const mode = options.mode ?? 'production';
23
+ const devMode = mode === 'development';
23
24
  if (!fs.existsSync(manifestPath)) {
24
25
  return null;
25
26
  }
@@ -139,12 +140,11 @@ function createPagesHandler(options) {
139
140
  );
140
141
  } catch (err) {
141
142
  if (manifest.errorBundle && !res.headersSent) {
142
- const errorMessage =
143
- process.env.NODE_ENV === 'production'
144
- ? 'An unexpected error occurred'
145
- : err instanceof Error
146
- ? err.message
147
- : String(err);
143
+ const errorMessage = devMode
144
+ ? err instanceof Error
145
+ ? err.message
146
+ : String(err)
147
+ : 'An unexpected error occurred';
148
148
  await renderErrorPage(
149
149
  manifest.errorBundle,
150
150
  500,
@@ -6,21 +6,23 @@ class PagesRouter {
6
6
  config;
7
7
  rootDir;
8
8
  log;
9
+ mode;
9
10
  fileWatcher;
10
11
  handler = null;
11
12
  watcher = null;
12
13
 
13
- constructor(config, { rootDir, log, fileWatcher, appContext }) {
14
+ constructor(config, { rootDir, log, mode, fileWatcher, appContext }) {
14
15
  this.config = config;
15
16
  this.rootDir = rootDir;
16
17
  this.log = log;
18
+ this.mode = mode ?? 'production';
17
19
  this.fileWatcher = fileWatcher ?? null;
18
20
  this.appContext = appContext ?? null;
19
21
  }
20
22
 
21
23
  async init() {
22
- const { config, rootDir, log, fileWatcher } = this;
23
- const isDev = fileWatcher !== null;
24
+ const { config, rootDir, log, mode, fileWatcher } = this;
25
+ const isDev = mode === 'development';
24
26
 
25
27
  let initialClientMetafile;
26
28
  if (isDev) {
@@ -42,14 +44,14 @@ class PagesRouter {
42
44
  rootDir,
43
45
  session: config.session,
44
46
  appContext: this.appContext,
45
- devMode: isDev,
47
+ mode,
46
48
  });
47
49
 
48
50
  if (!this.handler) return;
49
51
 
50
52
  log.info('Pages: SSR enabled');
51
53
 
52
- if (isDev) {
54
+ if (isDev && fileWatcher) {
53
55
  this.watcher = createPagesWatcher({
54
56
  rootDir,
55
57
  handler: this.handler,
@@ -82,6 +82,7 @@ async function serializeResponse(res, response, responseHeaders, statusCode) {
82
82
  class ApiRouter {
83
83
  _config;
84
84
  _log;
85
+ _mode;
85
86
  _fileWatcher;
86
87
  _appContext;
87
88
  _sessionConfig;
@@ -91,9 +92,10 @@ class ApiRouter {
91
92
  _reloading = false;
92
93
  _pendingReload = false;
93
94
 
94
- constructor(config, { log, fileWatcher, appContext, sessionConfig } = {}) {
95
+ constructor(config, { log, mode, fileWatcher, appContext, sessionConfig } = {}) {
95
96
  this._config = config;
96
97
  this._log = log;
98
+ this._mode = mode ?? 'production';
97
99
  this._fileWatcher = fileWatcher ?? null;
98
100
  this._appContext = appContext ?? null;
99
101
  this._sessionConfig = sessionConfig ?? null;
@@ -176,7 +178,7 @@ class ApiRouter {
176
178
  this._prefix = normalizePrefix(this._config.pathPrefix);
177
179
  this._log?.info('Discovering routes...');
178
180
  await this._discover();
179
- if (this._fileWatcher) {
181
+ if (this._mode === 'development' && this._fileWatcher) {
180
182
  this._fileWatcher.subscribe('routes', {
181
183
  dirs: ['api'],
182
184
  extensions: ['.js'],
@@ -303,7 +305,7 @@ class ApiRouter {
303
305
  }
304
306
 
305
307
  async close() {
306
- if (this._fileWatcher) {
308
+ if (this._mode === 'development' && this._fileWatcher) {
307
309
  this._fileWatcher.unsubscribe('routes');
308
310
  }
309
311
  }
@@ -28,6 +28,7 @@ function resolveSessionConfig(config, mode) {
28
28
  password: config.password,
29
29
  cookieName: config.cookieName ?? 'arcway.session',
30
30
  ttl: config.ttl ?? 14 * 24 * 3600,
31
+ mode: mode ?? 'production',
31
32
  cookie: {
32
33
  httpOnly: config.cookie?.httpOnly ?? true,
33
34
  secure: config.cookie?.secure ?? mode === 'production',
@@ -46,7 +47,7 @@ async function unsealSession(cookieValue, config) {
46
47
  });
47
48
  return data && typeof data === 'object' ? data : {};
48
49
  } catch (err) {
49
- if (process.env.NODE_ENV !== 'production') {
50
+ if (config.mode === 'development') {
50
51
  const reason = toErrorMessage(err);
51
52
  console.warn(`[arcway] Failed to unseal session cookie: ${reason}`);
52
53
  }