arcway 0.1.21 → 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.
@@ -0,0 +1,640 @@
1
+ import path from 'node:path';
2
+ import os from 'node:os';
3
+ import fs from 'node:fs/promises';
4
+ import { EventEmitter } from 'node:events';
5
+ import esbuild from 'esbuild';
6
+ import pLimit from 'p-limit';
7
+ import {
8
+ buildPageManifestEntry,
9
+ discoverPages,
10
+ discoverLayouts,
11
+ discoverLoadings,
12
+ discoverPageMiddleware,
13
+ discoverErrorPages,
14
+ discoverStyles,
15
+ resolveLayoutChain,
16
+ resolveLoadingChain,
17
+ resolvePageMiddlewareChain,
18
+ mapFileToAffected,
19
+ } from './discovery.js';
20
+ import { buildWithCache } from './build-cache.js';
21
+ import {
22
+ jsxEsbuildOptions,
23
+ plainEsbuildOptions,
24
+ patternToFileName,
25
+ layoutDirToFileName,
26
+ buildErrorPageBundles,
27
+ } from './build-server.js';
28
+ import { createClientBuildContext } from './build-client.js';
29
+ import { buildCssBundle } from './build-css.js';
30
+ import { resolveFonts } from './fonts.js';
31
+ import { buildHmrRuntimeBundle } from './hmr.js';
32
+
33
+ // Dev-mode build context that defers per-page/layout/middleware server-bundle
34
+ // compilation to first request. Startup is cheap: discover routes, build the
35
+ // client bundle + CSS + error pages eagerly (unavoidable — they're shared
36
+ // across all routes), and emit an in-memory manifest where every server
37
+ // entry is flagged `built: false`. Handler calls `ensurePageBuilt(pattern)`
38
+ // before rendering; concurrent calls for the same pattern coalesce to one
39
+ // build. A single shared `p-limit(cpuCount)` gates every buildWithCache call
40
+ // so a request burst can't saturate CPU.
41
+ //
42
+ // Prod is untouched — `buildPages()` and `createPagesBuildContext()` still
43
+ // fully build every server bundle upfront and atomically swap into place.
44
+
45
+ async function createLazyPagesContext(options) {
46
+ const { rootDir } = options;
47
+ const pagesDir = options.pagesDir ?? path.join(rootDir, 'pages');
48
+ const outDir = path.resolve(rootDir, options.outDir ?? '.build/pages');
49
+ const serverTarget = options.serverTarget ?? 'node22';
50
+ const clientTarget = options.clientTarget ?? 'es2022';
51
+ const minify = options.minify ?? false;
52
+ const devMode = options.devMode ?? true;
53
+ const esbuildImpl = options.esbuild ?? esbuild;
54
+ // Cap per-context server-bundle concurrency so a request burst after a
55
+ // cold start can't spawn N pages × M esbuild Go workers simultaneously.
56
+ // Defaults to logical core count for parity with today's eager fan-out on
57
+ // a single-user dev box; callers override in tests to assert coalescing.
58
+ const maxConcurrency = options.concurrency ?? os.cpus().length;
59
+ const limit = pLimit(Math.max(1, maxConcurrency));
60
+
61
+ const events = new EventEmitter();
62
+ events.setMaxListeners(0);
63
+
64
+ let disposed = false;
65
+ let clientCtx = null;
66
+ let fontFaceCss;
67
+ let fontPreloadHtml;
68
+
69
+ if (options.fonts && options.fonts.length > 0) {
70
+ const publicDir = path.join(rootDir, 'public');
71
+ const fontResult = await resolveFonts(options.fonts, publicDir);
72
+ if (fontResult.fontFaceCss) fontFaceCss = fontResult.fontFaceCss;
73
+ if (fontResult.preloadHtml) fontPreloadHtml = fontResult.preloadHtml;
74
+ }
75
+
76
+ // Discover routes up front. Structural changes (new/deleted files) are
77
+ // picked up later via the watcher's re-discovery path (phase 5).
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
+ await fs.mkdir(path.join(outDir, 'server'), { recursive: true });
88
+ await fs.mkdir(path.join(outDir, 'client'), { recursive: true });
89
+
90
+ const manifest = {
91
+ entries: pages.map((page) => buildPageManifestEntry(page, { layouts, middlewares, loadings })),
92
+ layouts: new Map(
93
+ layouts.map((l) => [
94
+ l.dirPath,
95
+ { srcPath: path.resolve(l.filePath), built: false, stale: false },
96
+ ]),
97
+ ),
98
+ middlewares: new Map(
99
+ middlewares.map((m) => [
100
+ m.dirPath,
101
+ { srcPath: path.resolve(m.filePath), built: false, stale: false },
102
+ ]),
103
+ ),
104
+ version: 0,
105
+ };
106
+
107
+ if (fontPreloadHtml) manifest.fontPreloadHtml = fontPreloadHtml;
108
+
109
+ // In-flight coalescing: when a burst of concurrent requests hits an unbuilt
110
+ // page, only the first triggers a build; the rest await the same promise.
111
+ const inFlightPages = new Map();
112
+ const inFlightLayouts = new Map();
113
+ const inFlightMiddlewares = new Map();
114
+
115
+ function bumpVersion() {
116
+ manifest.version += 1;
117
+ }
118
+
119
+ function emit(event, payload) {
120
+ events.emit(event, payload);
121
+ }
122
+
123
+ // Client bundle + CSS + error pages + HMR runtime are built eagerly at
124
+ // startup. They're shared across every request and their compilation cost
125
+ // is bounded (a single esbuild.context for the client, one esbuild.transform
126
+ // for CSS), so there's no win in deferring them.
127
+ async function eagerSharedBuilds() {
128
+ clientCtx = await createClientBuildContext(
129
+ pages,
130
+ layouts,
131
+ outDir,
132
+ clientTarget,
133
+ minify,
134
+ rootDir,
135
+ loadings,
136
+ minify ? 'production' : 'development',
137
+ devMode,
138
+ );
139
+ const [clientResult, cssBundle, errorBundles] = await Promise.all([
140
+ clientCtx.rebuild(),
141
+ buildCssBundle(pagesDir, outDir, minify, stylesPath, fontFaceCss, rootDir),
142
+ buildErrorPageBundles(errorPages, outDir, serverTarget, {
143
+ rootDir,
144
+ devMode,
145
+ minify,
146
+ limit,
147
+ }),
148
+ devMode ? buildHmrRuntimeBundle(outDir) : Promise.resolve(),
149
+ ]);
150
+
151
+ // Fold client bundle output into the manifest so renderPage() can find
152
+ // navBundle / layoutClientBundles / shared chunks immediately, the same
153
+ // as it would from a disk-backed prod manifest.
154
+ for (const entry of manifest.entries) {
155
+ const clientBundle = clientResult.bundles.get(entry.pattern);
156
+ const navBundle = clientResult.navBundles.get(entry.pattern);
157
+ if (clientBundle) entry.clientBundle = clientBundle;
158
+ if (navBundle) entry.navBundle = navBundle;
159
+ entry.layoutClientBundles = entry.layoutDirs
160
+ .map((d) => clientResult.layoutNavBundles.get(d))
161
+ .filter((v) => v !== undefined);
162
+ entry.loadingClientBundles = entry.loadingDirs
163
+ .map((d) => clientResult.loadingNavBundles.get(d))
164
+ .filter((v) => v !== undefined);
165
+ entry.sharedChunks = clientResult.entryChunks?.get(entry.pattern) ?? [];
166
+ entry.sharedCssChunks = clientResult.entryCssChunks?.get(entry.pattern) ?? [];
167
+ }
168
+ if (cssBundle) manifest.cssBundle = cssBundle;
169
+ if (errorBundles.error) manifest.errorBundle = errorBundles.error;
170
+ if (errorBundles.notFound) manifest.notFoundBundle = errorBundles.notFound;
171
+ bumpVersion();
172
+ emit('update', { type: 'startup', version: manifest.version });
173
+ return { clientMetafile: clientResult.metafile };
174
+ }
175
+
176
+ const startup = await eagerSharedBuilds();
177
+
178
+ function findEntry(pattern) {
179
+ return manifest.entries.find((e) => e.pattern === pattern) ?? null;
180
+ }
181
+
182
+ async function buildPageServer(entry) {
183
+ const outName = patternToFileName(entry.pattern);
184
+ const outFile = path.join(outDir, 'server', `${outName}.js`);
185
+ await buildWithCache({
186
+ rootDir,
187
+ kind: 'server',
188
+ entryPath: entry.srcPath,
189
+ outFile,
190
+ esbuildOptions: jsxEsbuildOptions(entry.srcPath, outFile, serverTarget),
191
+ esbuild: esbuildImpl,
192
+ devMode,
193
+ minify,
194
+ });
195
+ entry.serverBundle = path.relative(outDir, outFile).replace(/\\/g, '/');
196
+ entry.built = true;
197
+ entry.stale = false;
198
+ entry.lastBuiltAt = Date.now();
199
+ }
200
+
201
+ async function buildLayoutServer(dirPath) {
202
+ const layout = manifest.layouts.get(dirPath);
203
+ if (!layout) return;
204
+ const outName = layoutDirToFileName(dirPath);
205
+ const outFile = path.join(outDir, 'server', `_layout_${outName}.js`);
206
+ await buildWithCache({
207
+ rootDir,
208
+ kind: 'layout',
209
+ entryPath: layout.srcPath,
210
+ outFile,
211
+ esbuildOptions: jsxEsbuildOptions(layout.srcPath, outFile, serverTarget),
212
+ esbuild: esbuildImpl,
213
+ devMode,
214
+ minify,
215
+ });
216
+ layout.serverBundle = path.relative(outDir, outFile).replace(/\\/g, '/');
217
+ layout.built = true;
218
+ layout.stale = false;
219
+ layout.lastBuiltAt = Date.now();
220
+ }
221
+
222
+ async function buildMiddlewareServer(dirPath) {
223
+ const mw = manifest.middlewares.get(dirPath);
224
+ if (!mw) return;
225
+ const outName = layoutDirToFileName(dirPath);
226
+ const outFile = path.join(outDir, 'server', `_middleware_${outName}.js`);
227
+ await buildWithCache({
228
+ rootDir,
229
+ kind: 'middleware',
230
+ entryPath: mw.srcPath,
231
+ outFile,
232
+ esbuildOptions: plainEsbuildOptions(mw.srcPath, outFile, serverTarget),
233
+ esbuild: esbuildImpl,
234
+ devMode,
235
+ minify,
236
+ });
237
+ mw.serverBundle = path.relative(outDir, outFile).replace(/\\/g, '/');
238
+ mw.built = true;
239
+ mw.stale = false;
240
+ mw.lastBuiltAt = Date.now();
241
+ }
242
+
243
+ function ensureLayoutBuilt(dirPath) {
244
+ const layout = manifest.layouts.get(dirPath);
245
+ if (!layout) return Promise.reject(new Error(`Unknown layout dirPath: ${dirPath}`));
246
+ if (layout.built && !layout.stale) return Promise.resolve();
247
+ const existing = inFlightLayouts.get(dirPath);
248
+ if (existing) return existing;
249
+ const task = limit(() => buildLayoutServer(dirPath))
250
+ .then(() => {
251
+ emit('update', { type: 'built', kind: 'layout', dirPath, version: ++manifest.version });
252
+ })
253
+ .finally(() => inFlightLayouts.delete(dirPath));
254
+ inFlightLayouts.set(dirPath, task);
255
+ return task;
256
+ }
257
+
258
+ function ensureMiddlewareBuilt(dirPath) {
259
+ const mw = manifest.middlewares.get(dirPath);
260
+ if (!mw) return Promise.reject(new Error(`Unknown middleware dirPath: ${dirPath}`));
261
+ if (mw.built && !mw.stale) return Promise.resolve();
262
+ const existing = inFlightMiddlewares.get(dirPath);
263
+ if (existing) return existing;
264
+ const task = limit(() => buildMiddlewareServer(dirPath))
265
+ .then(() => {
266
+ emit('update', { type: 'built', kind: 'middleware', dirPath, version: ++manifest.version });
267
+ })
268
+ .finally(() => inFlightMiddlewares.delete(dirPath));
269
+ inFlightMiddlewares.set(dirPath, task);
270
+ return task;
271
+ }
272
+
273
+ function ensurePageBuilt(pattern) {
274
+ const entry = findEntry(pattern);
275
+ if (!entry) return Promise.reject(new Error(`Unknown page pattern: ${pattern}`));
276
+ if (entry.built && !entry.stale) {
277
+ // Still need to ensure layouts/middlewares stay current; they can be
278
+ // invalidated independently of the page itself.
279
+ return ensureChainBuilt(entry);
280
+ }
281
+ const existing = inFlightPages.get(pattern);
282
+ if (existing) return existing;
283
+ const task = Promise.all([
284
+ limit(() => buildPageServer(entry)),
285
+ ensureChainBuilt(entry),
286
+ ])
287
+ .then(() => {
288
+ entry.layoutServerBundles = entry.layoutDirs
289
+ .map((d) => manifest.layouts.get(d)?.serverBundle)
290
+ .filter((v) => v !== undefined);
291
+ entry.middlewareServerBundles = entry.middlewareDirs
292
+ .map((d) => manifest.middlewares.get(d)?.serverBundle)
293
+ .filter((v) => v !== undefined);
294
+ emit('update', { type: 'built', kind: 'page', pattern, version: ++manifest.version });
295
+ })
296
+ .finally(() => inFlightPages.delete(pattern));
297
+ inFlightPages.set(pattern, task);
298
+ return task;
299
+ }
300
+
301
+ function ensureChainBuilt(entry) {
302
+ const jobs = [];
303
+ for (const dirPath of entry.layoutDirs) jobs.push(ensureLayoutBuilt(dirPath));
304
+ for (const dirPath of entry.middlewareDirs) jobs.push(ensureMiddlewareBuilt(dirPath));
305
+ return Promise.all(jobs).then(() => {
306
+ // Re-stitch the page's bundle list after the chain settles so any
307
+ // layout that just finished building is reflected on the entry.
308
+ entry.layoutServerBundles = entry.layoutDirs
309
+ .map((d) => manifest.layouts.get(d)?.serverBundle)
310
+ .filter((v) => v !== undefined);
311
+ entry.middlewareServerBundles = entry.middlewareDirs
312
+ .map((d) => manifest.middlewares.get(d)?.serverBundle)
313
+ .filter((v) => v !== undefined);
314
+ });
315
+ }
316
+
317
+ // Mark every manifest entry affected by a source change as stale. Does not
318
+ // trigger rebuilds directly — the next `ensurePageBuilt()` call (driven by
319
+ // an incoming request) handles the actual work. Structural changes
320
+ // (add/remove files) are not handled here; the watcher will re-init the
321
+ // context or extend the manifest in phase 5.
322
+ function invalidate(filePath) {
323
+ const affected = mapFileToAffected(filePath, manifest);
324
+ let touched = false;
325
+ for (const pattern of affected.pages) {
326
+ const entry = findEntry(pattern);
327
+ if (entry) {
328
+ entry.stale = true;
329
+ entry.built = false;
330
+ touched = true;
331
+ }
332
+ }
333
+ for (const dirPath of affected.layouts) {
334
+ const layout = manifest.layouts.get(dirPath);
335
+ if (layout) {
336
+ layout.stale = true;
337
+ layout.built = false;
338
+ touched = true;
339
+ }
340
+ }
341
+ for (const dirPath of affected.middlewares) {
342
+ const mw = manifest.middlewares.get(dirPath);
343
+ if (mw) {
344
+ mw.stale = true;
345
+ mw.built = false;
346
+ touched = true;
347
+ }
348
+ }
349
+ if (touched) {
350
+ bumpVersion();
351
+ emit('update', { type: 'invalidate', filePath, affected, version: manifest.version });
352
+ }
353
+ return { touched, affected };
354
+ }
355
+
356
+ // ── Structural rediscovery ─────────────────────────────────────────────
357
+ // `invalidate()` handles content edits to known files; `rediscover()`
358
+ // handles add/delete of pages, layouts, and middlewares. Re-runs the same
359
+ // discovery helpers used at startup, diffs the result against the in-memory
360
+ // manifest, and applies adds/removes plus a cascade re-resolution of every
361
+ // surviving entry's chain (added/removed layouts and middlewares mutate
362
+ // descendants' chains). The client esbuild context is torn down and
363
+ // recreated when the page or layout set changes — middleware-only changes
364
+ // don't touch the client bundle. Concurrent calls coalesce to a single
365
+ // in-flight discovery so a burst (e.g. `git checkout` swap) triggers one
366
+ // pass, not N.
367
+ let inFlightRediscover = null;
368
+
369
+ async function rediscover() {
370
+ if (disposed) throw new Error('Lazy pages context is disposed');
371
+ if (inFlightRediscover) return inFlightRediscover;
372
+ const task = (async () => {
373
+ const [
374
+ nextPages,
375
+ nextLayouts,
376
+ nextLoadings,
377
+ nextMiddlewares,
378
+ ] = await Promise.all([
379
+ discoverPages(pagesDir),
380
+ discoverLayouts(pagesDir),
381
+ discoverLoadings(pagesDir),
382
+ discoverPageMiddleware(pagesDir),
383
+ ]);
384
+
385
+ const result = {
386
+ added: { pages: [], layouts: [], middlewares: [] },
387
+ removed: { pages: [], layouts: [], middlewares: [] },
388
+ staleByCascade: [],
389
+ clientRebuilt: false,
390
+ version: manifest.version,
391
+ };
392
+
393
+ // ── Diff ─────────────────────────────────────────────────────────
394
+ const currentPagePatterns = new Set(manifest.entries.map((e) => e.pattern));
395
+ const nextPagePatterns = new Set(nextPages.map((p) => p.pattern));
396
+ const addedPages = nextPages.filter((p) => !currentPagePatterns.has(p.pattern));
397
+ const removedPages = manifest.entries.filter((e) => !nextPagePatterns.has(e.pattern));
398
+
399
+ const currentLayoutDirs = new Set(manifest.layouts.keys());
400
+ const nextLayoutDirs = new Set(nextLayouts.map((l) => l.dirPath));
401
+ const addedLayouts = nextLayouts.filter((l) => !currentLayoutDirs.has(l.dirPath));
402
+ const removedLayouts = [...currentLayoutDirs].filter((d) => !nextLayoutDirs.has(d));
403
+
404
+ const currentMwDirs = new Set(manifest.middlewares.keys());
405
+ const nextMwDirs = new Set(nextMiddlewares.map((m) => m.dirPath));
406
+ const addedMiddlewares = nextMiddlewares.filter((m) => !currentMwDirs.has(m.dirPath));
407
+ const removedMiddlewares = [...currentMwDirs].filter((d) => !nextMwDirs.has(d));
408
+
409
+ const noOp =
410
+ addedPages.length === 0 &&
411
+ removedPages.length === 0 &&
412
+ addedLayouts.length === 0 &&
413
+ removedLayouts.length === 0 &&
414
+ addedMiddlewares.length === 0 &&
415
+ removedMiddlewares.length === 0;
416
+ if (noOp) return result;
417
+
418
+ // ── Apply layout add/remove ──────────────────────────────────────
419
+ for (const layout of addedLayouts) {
420
+ manifest.layouts.set(layout.dirPath, {
421
+ srcPath: path.resolve(layout.filePath),
422
+ built: false,
423
+ stale: false,
424
+ });
425
+ result.added.layouts.push(layout.dirPath);
426
+ }
427
+ for (const dirPath of removedLayouts) {
428
+ manifest.layouts.delete(dirPath);
429
+ inFlightLayouts.delete(dirPath);
430
+ result.removed.layouts.push(dirPath);
431
+ }
432
+
433
+ // ── Apply middleware add/remove ──────────────────────────────────
434
+ for (const mw of addedMiddlewares) {
435
+ manifest.middlewares.set(mw.dirPath, {
436
+ srcPath: path.resolve(mw.filePath),
437
+ built: false,
438
+ stale: false,
439
+ });
440
+ result.added.middlewares.push(mw.dirPath);
441
+ }
442
+ for (const dirPath of removedMiddlewares) {
443
+ manifest.middlewares.delete(dirPath);
444
+ inFlightMiddlewares.delete(dirPath);
445
+ result.removed.middlewares.push(dirPath);
446
+ }
447
+
448
+ // ── Apply page add/remove ────────────────────────────────────────
449
+ const addedPagePatterns = new Set();
450
+ for (const page of addedPages) {
451
+ manifest.entries.push(
452
+ buildPageManifestEntry(page, {
453
+ layouts: nextLayouts,
454
+ middlewares: nextMiddlewares,
455
+ loadings: nextLoadings,
456
+ }),
457
+ );
458
+ result.added.pages.push(page.pattern);
459
+ addedPagePatterns.add(page.pattern);
460
+ }
461
+ for (const removed of removedPages) {
462
+ const idx = manifest.entries.findIndex((e) => e.pattern === removed.pattern);
463
+ if (idx >= 0) manifest.entries.splice(idx, 1);
464
+ inFlightPages.delete(removed.pattern);
465
+ result.removed.pages.push(removed.pattern);
466
+ }
467
+
468
+ // ── Cascade: re-resolve chains for surviving entries; mark stale on diff
469
+ for (const entry of manifest.entries) {
470
+ if (addedPagePatterns.has(entry.pattern)) continue; // freshly built, already current
471
+ const newLayoutDirs = resolveLayoutChain(entry.pattern, nextLayouts).map((l) => l.dirPath);
472
+ const newMwDirs = resolvePageMiddlewareChain(entry.pattern, nextMiddlewares).map(
473
+ (m) => m.dirPath,
474
+ );
475
+ const newLoadingDirs = resolveLoadingChain(entry.pattern, nextLoadings).map(
476
+ (l) => l.dirPath,
477
+ );
478
+ const changed =
479
+ !arraysEqual(entry.layoutDirs, newLayoutDirs) ||
480
+ !arraysEqual(entry.middlewareDirs, newMwDirs) ||
481
+ !arraysEqual(entry.loadingDirs, newLoadingDirs);
482
+ if (changed) {
483
+ entry.layoutDirs = newLayoutDirs;
484
+ entry.middlewareDirs = newMwDirs;
485
+ entry.loadingDirs = newLoadingDirs;
486
+ entry.stale = true;
487
+ entry.built = false;
488
+ result.staleByCascade.push(entry.pattern);
489
+ }
490
+ }
491
+
492
+ // ── Client bundle rebuild on page or layout structural changes ───
493
+ const needClientRebuild =
494
+ result.added.pages.length > 0 ||
495
+ result.removed.pages.length > 0 ||
496
+ result.added.layouts.length > 0 ||
497
+ result.removed.layouts.length > 0;
498
+ if (needClientRebuild) {
499
+ if (clientCtx) {
500
+ try {
501
+ await clientCtx.dispose();
502
+ } catch {
503
+ /* ignore — we're replacing it anyway */
504
+ }
505
+ }
506
+ clientCtx = await createClientBuildContext(
507
+ nextPages,
508
+ nextLayouts,
509
+ outDir,
510
+ clientTarget,
511
+ minify,
512
+ rootDir,
513
+ nextLoadings,
514
+ minify ? 'production' : 'development',
515
+ devMode,
516
+ );
517
+ const clientResult = await clientCtx.rebuild();
518
+ for (const entry of manifest.entries) {
519
+ const clientBundle = clientResult.bundles.get(entry.pattern);
520
+ const navBundle = clientResult.navBundles.get(entry.pattern);
521
+ if (clientBundle) entry.clientBundle = clientBundle;
522
+ if (navBundle) entry.navBundle = navBundle;
523
+ entry.layoutClientBundles = entry.layoutDirs
524
+ .map((d) => clientResult.layoutNavBundles.get(d))
525
+ .filter((v) => v !== undefined);
526
+ entry.loadingClientBundles = entry.loadingDirs
527
+ .map((d) => clientResult.loadingNavBundles.get(d))
528
+ .filter((v) => v !== undefined);
529
+ entry.sharedChunks = clientResult.entryChunks?.get(entry.pattern) ?? [];
530
+ entry.sharedCssChunks = clientResult.entryCssChunks?.get(entry.pattern) ?? [];
531
+ }
532
+ result.clientRebuilt = true;
533
+ }
534
+
535
+ // ── Version bump + event emission ────────────────────────────────
536
+ bumpVersion();
537
+ result.version = manifest.version;
538
+ for (const pattern of result.added.pages) {
539
+ emit('update', { type: 'add-page', pattern, version: manifest.version });
540
+ }
541
+ for (const pattern of result.removed.pages) {
542
+ emit('update', { type: 'remove-page', pattern, version: manifest.version });
543
+ }
544
+ for (const dirPath of result.added.layouts) {
545
+ emit('update', { type: 'add-layout', dirPath, version: manifest.version });
546
+ }
547
+ for (const dirPath of result.removed.layouts) {
548
+ emit('update', { type: 'remove-layout', dirPath, version: manifest.version });
549
+ }
550
+ for (const dirPath of result.added.middlewares) {
551
+ emit('update', { type: 'add-middleware', dirPath, version: manifest.version });
552
+ }
553
+ for (const dirPath of result.removed.middlewares) {
554
+ emit('update', { type: 'remove-middleware', dirPath, version: manifest.version });
555
+ }
556
+ if (result.clientRebuilt) {
557
+ emit('update', { type: 'client-rebuilt', version: manifest.version });
558
+ }
559
+
560
+ return result;
561
+ })().finally(() => {
562
+ inFlightRediscover = null;
563
+ });
564
+ inFlightRediscover = task;
565
+ return task;
566
+ }
567
+
568
+ function arraysEqual(a, b) {
569
+ if (a === b) return true;
570
+ if (!a || !b || a.length !== b.length) return false;
571
+ for (let i = 0; i < a.length; i++) {
572
+ if (a[i] !== b[i]) return false;
573
+ }
574
+ return true;
575
+ }
576
+
577
+ function on(event, cb) {
578
+ events.on(event, cb);
579
+ return () => events.off(event, cb);
580
+ }
581
+
582
+ // Public, serializable snapshot for the `/_arcway/manifest.json` endpoint
583
+ // (phase 4). Strips absolute source paths and internal bookkeeping so a
584
+ // dev overlay can't infer disk layout.
585
+ function getManifestJson() {
586
+ return {
587
+ version: manifest.version,
588
+ entries: manifest.entries.map((entry) => ({
589
+ pattern: entry.pattern,
590
+ built: entry.built,
591
+ stale: entry.stale,
592
+ ...(entry.lastBuiltAt ? { lastBuiltAt: entry.lastBuiltAt } : {}),
593
+ })),
594
+ layouts: Array.from(manifest.layouts.entries()).map(([dirPath, l]) => ({
595
+ dirPath,
596
+ built: l.built,
597
+ stale: l.stale,
598
+ ...(l.lastBuiltAt ? { lastBuiltAt: l.lastBuiltAt } : {}),
599
+ })),
600
+ middlewares: Array.from(manifest.middlewares.entries()).map(([dirPath, m]) => ({
601
+ dirPath,
602
+ built: m.built,
603
+ stale: m.stale,
604
+ ...(m.lastBuiltAt ? { lastBuiltAt: m.lastBuiltAt } : {}),
605
+ })),
606
+ ...(manifest.errorBundle ? { errorBundle: manifest.errorBundle } : {}),
607
+ ...(manifest.notFoundBundle ? { notFoundBundle: manifest.notFoundBundle } : {}),
608
+ ...(manifest.cssBundle ? { cssBundle: manifest.cssBundle } : {}),
609
+ };
610
+ }
611
+
612
+ async function dispose() {
613
+ if (disposed) return;
614
+ disposed = true;
615
+ if (clientCtx) {
616
+ await clientCtx.dispose();
617
+ clientCtx = null;
618
+ }
619
+ events.removeAllListeners();
620
+ }
621
+
622
+ return {
623
+ manifest,
624
+ outDir,
625
+ rootDir,
626
+ ensurePageBuilt,
627
+ ensureLayoutBuilt,
628
+ ensureMiddlewareBuilt,
629
+ invalidate,
630
+ rediscover,
631
+ dispose,
632
+ on,
633
+ getManifestJson,
634
+ get clientMetafile() {
635
+ return startup.clientMetafile;
636
+ },
637
+ };
638
+ }
639
+
640
+ export { createLazyPagesContext };