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 CHANGED
@@ -96,6 +96,10 @@ project-root/
96
96
  ├── listeners/ # Event subscribers (folder path = event name)
97
97
  │ └── users/
98
98
  │ └── created.js # Handles 'users/created' event
99
+ ├── hooks/ # Lifecycle hooks (init / ready / shutdown)
100
+ │ ├── init.js # Runs before listeners/jobs/routers are wired
101
+ │ ├── ready.js # Runs after the server is listening
102
+ │ └── shutdown.js # Runs at the top of graceful shutdown
99
103
  ├── jobs/ # Background job definitions
100
104
  │ └── send-welcome-email.js
101
105
  ├── migrations/ # Database migrations (timestamp-ordered)
@@ -135,6 +139,15 @@ export default {
135
139
  database: {
136
140
  client: 'postgres', // 'sqlite', 'postgres', 'mysql'
137
141
  connection: 'postgres://user:pass@localhost/mydb',
142
+ hooks: { // Forwarded verbatim to knex — postProcessResponse,
143
+ // wrapIdentifier, asyncStackTraces, log, debug.
144
+ postProcessResponse: (result, ctx) => {
145
+ if (ctx?.table === 'users' && Array.isArray(result)) {
146
+ return result.map((row) => ({ ...row, createdAt: new Date(row.created_at) }));
147
+ }
148
+ return result;
149
+ },
150
+ },
138
151
  },
139
152
  session: {
140
153
  password: 'at-least-32-character-secret-here!!',
@@ -253,6 +266,38 @@ export const POST = {
253
266
  };
254
267
  ```
255
268
 
269
+ ### Database hooks & per-table decoding
270
+
271
+ Knex's `postProcessResponse` hook (plus `wrapIdentifier`, `log`, `debug`, and
272
+ `asyncStackTraces`) can be set under `database.hooks` in `arcway.config.js` and
273
+ are forwarded to the underlying knex instance.
274
+
275
+ To make `postProcessResponse` useful without threading context at every call
276
+ site, arcway auto-stamps `.queryContext({ table })` on every `ctx.db(name)`
277
+ builder. Your hook can key on `ctx.table` to decode rows centrally:
278
+
279
+ ```js
280
+ // arcway.config.js
281
+ database: {
282
+ hooks: {
283
+ postProcessResponse: (result, ctx) => {
284
+ if (!ctx?.table || !Array.isArray(result)) return result;
285
+ const decode = decoders[ctx.table];
286
+ return decode ? result.map(decode) : result;
287
+ },
288
+ },
289
+ },
290
+ ```
291
+
292
+ `ctx.db.raw(...)`, `ctx.db({ alias: ... })`, and subquery builders are not
293
+ stamped — hooks should pass those through with `if (!ctx?.table) return result`.
294
+ To carry additional context alongside the auto-stamp, merge instead of replace:
295
+
296
+ ```js
297
+ const builder = ctx.db('orders');
298
+ builder.queryContext({ ...builder.queryContext(), userId: ctx.req.session.userId });
299
+ ```
300
+
256
301
  ### Handler Context
257
302
 
258
303
  Every route handler receives a `ctx` object with infrastructure and request data:
@@ -410,21 +455,37 @@ export default async (ctx) => {
410
455
 
411
456
  The listener context includes all infrastructure (`db`, `events`, `cache`, `queue`, `files`, `mail`, `log`) plus `event: { name, payload }`.
412
457
 
413
- ### System Lifecycle Events
458
+ ## Lifecycle Hooks
414
459
 
415
- Special listeners in `listeners/system/` handle lifecycle events:
460
+ Lifecycle hooks live in the top-level `hooks/` directory. Each file default-exports an `async` function that receives the **real, mutable `appContext`** — the same object downstream subsystems (listeners, jobs, routes) read services from at call time. Mutations (e.g. wrapping `appContext.db`) persist for the lifetime of the process.
461
+
462
+ ```
463
+ project-root/
464
+ └── hooks/
465
+ ├── init.js — after infrastructure, before listeners/jobs/routers are wired
466
+ ├── ready.js — after the HTTP server is listening and the job runner has started
467
+ └── shutdown.js — at the top of graceful shutdown, before any teardown
468
+ ```
416
469
 
417
470
  ```js
418
- // listeners/system/init.js — runs before server starts
419
- export default async (ctx) => { /* setup */ };
471
+ // hooks/init.js — wrap services, register global resources
472
+ export default async (appContext) => {
473
+ appContext.db = wrapWithTenancy(appContext.db);
474
+ };
420
475
 
421
- // listeners/system/ready.js — runs after server is listening
422
- export default async (ctx) => { /* post-boot */ };
476
+ // hooks/ready.js — post-boot work (warmups, one-shot recovery)
477
+ export default async (appContext) => {
478
+ await recoverOrphanedTasks(appContext);
479
+ };
423
480
 
424
- // listeners/system/shutdown.js — runs during graceful shutdown
425
- export default async (ctx) => { /* cleanup */ };
481
+ // hooks/shutdown.js — flush and cleanup before infrastructure is destroyed
482
+ export default async (appContext) => {
483
+ await appContext.cache.flush();
484
+ };
426
485
  ```
427
486
 
487
+ Hooks are optional — missing files are silently skipped. An existing file must default-export a function; otherwise boot fails fast.
488
+
428
489
  ## Jobs
429
490
 
430
491
  Background jobs support one-off queuing and cron scheduling.
@@ -0,0 +1,48 @@
1
+ import { createElement, lazy, Suspense } from 'react';
2
+
3
+ const isServer = typeof window === 'undefined';
4
+
5
+ // `arcway/dynamic` — component-level lazy loading with an SSR escape hatch.
6
+ //
7
+ // Returns a React component. Default behavior mirrors `React.lazy` wrapped in
8
+ // a local `<Suspense>` so callers don't have to place their own boundary:
9
+ //
10
+ // const Heavy = dynamic(() => import('./heavy.js'), { loading: Skeleton });
11
+ //
12
+ // `ssr: false` disables server rendering of the lazy child: under Node the
13
+ // component short-circuits to the loading fallback (or null), and the loader
14
+ // is never invoked server-side. The chunk is therefore absent from the SSR
15
+ // payload and is fetched on the client on first mount.
16
+ export default function dynamic(loader, opts = {}) {
17
+ const { ssr = true, loading } = opts;
18
+ const Fallback = loading ?? null;
19
+ const fallbackElement = Fallback ? createElement(Fallback) : null;
20
+
21
+ if (!ssr) {
22
+ // On the server we want zero loader activity and zero Suspense churn —
23
+ // just the fallback (or empty). On the client we still lazy-load so the
24
+ // chunk stays out of the initial payload; Suspense wraps it locally.
25
+ if (isServer) {
26
+ const ServerStub = () => fallbackElement;
27
+ ServerStub.displayName = 'DynamicServerStub';
28
+ return ServerStub;
29
+ }
30
+ const Lazy = lazy(loader);
31
+ const ClientOnly = (props) =>
32
+ createElement(Suspense, { fallback: fallbackElement }, createElement(Lazy, props));
33
+ ClientOnly.displayName = 'DynamicClientOnly';
34
+ return ClientOnly;
35
+ }
36
+
37
+ // Default SSR path: React.lazy + local Suspense. Under streaming SSR
38
+ // (renderToPipeableStream) the fallback is emitted first and the resolved
39
+ // chunk streams in when the loader promise settles. Under sync
40
+ // renderToString() the fallback is the final output, which is the expected
41
+ // behavior — pages needing a single-pass SSR render should use `ssr: false`
42
+ // or pre-import the module.
43
+ const Lazy = lazy(loader);
44
+ const Dynamic = (props) =>
45
+ createElement(Suspense, { fallback: fallbackElement }, createElement(Lazy, props));
46
+ Dynamic.displayName = 'Dynamic';
47
+ return Dynamic;
48
+ }
package/client/router.js CHANGED
@@ -103,7 +103,16 @@ function Router({
103
103
  return () => window.removeEventListener('manifest-update', onManifestUpdate);
104
104
  }, []);
105
105
 
106
- const applyLoaded = useCallback((loaded, newPath) => {
106
+ // `bumpQuery` controls whether `queryVersion` is incremented atomically
107
+ // with the page-state updates. It lives inside the same `startTransition`
108
+ // so React commits the new `pathname` and the re-read of
109
+ // `window.location.search` (triggered by `queryVersion`) in one go. Keeping
110
+ // the bump outside the transition raced at default priority and produced
111
+ // an interim render where `pathname` was stale but `useSearchParams` had
112
+ // already moved to the new query string — see the
113
+ // `query-version-outside-navigation-transition` bug report for the
114
+ // downstream data-corruption pattern that unlocked.
115
+ const applyLoaded = useCallback((loaded, newPath, bumpQuery = false) => {
107
116
  startTransition(() => {
108
117
  setPathname(newPath);
109
118
  setPageState({
@@ -113,6 +122,7 @@ function Router({
113
122
  params: loaded.params,
114
123
  });
115
124
  setIsNavigating(false);
125
+ if (bumpQuery) setQueryVersion((v) => v + 1);
116
126
  });
117
127
  }, []);
118
128
 
@@ -179,8 +189,7 @@ function Router({
179
189
  return;
180
190
  }
181
191
 
182
- applyLoaded(loaded, pathOnly);
183
- if (search !== currentSearch) setQueryVersion((v) => v + 1);
192
+ applyLoaded(loaded, pathOnly, search !== currentSearch);
184
193
 
185
194
  if (scroll) {
186
195
  window.scrollTo(0, 0);
@@ -216,8 +225,7 @@ function Router({
216
225
  try {
217
226
  const loaded = await loadPage(manifest, newPath);
218
227
  if (loaded) {
219
- applyLoaded(loaded, newPath);
220
- setQueryVersion((v) => v + 1);
228
+ applyLoaded(loaded, newPath, true);
221
229
  } else {
222
230
  window.location.reload();
223
231
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "arcway",
3
- "version": "0.1.20",
3
+ "version": "0.1.22",
4
4
  "description": "A convention-based framework for building modular monoliths with strict domain boundaries.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,6 +23,7 @@
23
23
  "./internals": "./server/internals.js",
24
24
  "./lib/vault": "./server/lib/vault/index.js",
25
25
  "./lib/client": "./client/index.js",
26
+ "./dynamic": "./client/dynamic.js",
26
27
  "./ui": "./client/ui/index.js",
27
28
  "./middlewares": "./server/middlewares/index.js",
28
29
  "./ui/theme.css": "./client/ui/theme.css",
@@ -78,6 +79,7 @@
78
79
  "lucide-react": "^0.564.0",
79
80
  "mailparser": "^3.9.3",
80
81
  "nanoid": "^5.1.6",
82
+ "negotiator": "^1.0.0",
81
83
  "nodemailer": "^8.0.1",
82
84
  "p-limit": "^7.3.0",
83
85
  "pg": "^8.13.0",
@@ -0,0 +1,19 @@
1
+ import path from 'node:path';
2
+ import fs from 'node:fs/promises';
3
+ import { loadModule } from '../module-loader.js';
4
+
5
+ async function runHook(name, appContext, rootDir) {
6
+ const filePath = path.join(rootDir, 'hooks', `${name}.js`);
7
+ try {
8
+ await fs.access(filePath);
9
+ } catch {
10
+ return;
11
+ }
12
+ const mod = await loadModule(filePath);
13
+ if (typeof mod.default !== 'function') {
14
+ throw new Error(`hooks/${name}.js must default-export an async function`);
15
+ }
16
+ await mod.default(appContext);
17
+ }
18
+
19
+ export { runHook };
@@ -13,6 +13,7 @@ import { SystemRouter } from '../system-routes/index.js';
13
13
  import { StaticRouter } from '../static/index.js';
14
14
  import { WsRouter } from '../ws/ws-router.js';
15
15
  import { createWsBackplane } from '../ws/backplane.js';
16
+ import { runHook } from './hooks.js';
16
17
  import path from 'node:path';
17
18
 
18
19
  async function boot(options) {
@@ -25,14 +26,25 @@ async function boot(options) {
25
26
  const infrastructure = await createInfrastructure(config, { runMigrations: true });
26
27
  const { db, redis, queue, cache, files, mail, events, log } = infrastructure;
27
28
 
28
- const mcpRouter = new McpRouter(config.mcp, { log });
29
-
30
29
  if (envFiles.length > 0) log.info('Env files loaded', { envFiles });
31
30
 
31
+ const fileWatcher = mode === 'development' ? new FileWatcher(rootDir, { log }) : null;
32
+ if (fileWatcher) await fileWatcher.start();
33
+
34
+ // Single mutable appContext reference. Handed to every subsystem that needs
35
+ // framework services and to the init/ready/shutdown hooks. Mutations from
36
+ // the init hook (e.g. wrapping `appContext.db`) persist for process lifetime
37
+ // because downstream consumers read properties off this reference at use time.
38
+ const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher };
39
+
40
+ await runHook('init', appContext, rootDir);
41
+
42
+ const mcpRouter = new McpRouter(config.mcp, { log });
43
+
32
44
  const eventHandler = new EventHandler(config.events, {
33
45
  events,
34
46
  log,
35
- appContext: { db, events, queue, cache, files, mail, log },
47
+ appContext,
36
48
  });
37
49
  await eventHandler.init();
38
50
 
@@ -60,11 +72,6 @@ async function boot(options) {
60
72
  });
61
73
  await jobRunner.init();
62
74
 
63
- const fileWatcher = mode === 'development' ? new FileWatcher(rootDir, { log }) : null;
64
- if (fileWatcher) await fileWatcher.start();
65
-
66
- const appContext = { db, redis, events, queue, cache, files, mail, log, fileWatcher };
67
-
68
75
  // ── Routers ──
69
76
 
70
77
  const apiRouter = new ApiRouter(config.api, {
@@ -115,7 +122,10 @@ async function boot(options) {
115
122
 
116
123
  jobRunner.start();
117
124
 
125
+ await runHook('ready', appContext, rootDir);
126
+
118
127
  const shutdown = async () => {
128
+ await runHook('shutdown', appContext, rootDir);
119
129
  await jobRunner.shutdown();
120
130
  if (workerPool) await workerPool.shutdown();
121
131
  await pagesRouter.close();
package/server/context.js CHANGED
@@ -18,6 +18,16 @@ function trackDb(db) {
18
18
  apply(target, thisArg, args) {
19
19
  const builder = Reflect.apply(target, thisArg, args);
20
20
  if (builder && typeof builder.then === 'function' && typeof builder.select === 'function') {
21
+ // Auto-stamp `.queryContext({ table })` for the canonical `db(name)` form
22
+ // so `postProcessResponse(result, ctx)` hooks can route on table without
23
+ // every call site having to thread it manually. Only stamp when the
24
+ // first arg is a string table name — db({ alias: … }) / db.raw(…) /
25
+ // subquery forms intentionally carry no table. Guard on queryContext
26
+ // existence so mock builders without it (see context tests) still work.
27
+ const [name] = args;
28
+ if (typeof name === 'string' && typeof builder.queryContext === 'function') {
29
+ builder.queryContext({ table: name });
30
+ }
21
31
  return wrapBuilder(builder, stats);
22
32
  }
23
33
  return builder;
@@ -26,12 +26,21 @@ async function createDB(config, { log } = {}) {
26
26
  const client = config.client;
27
27
  const isSqlite = client === 'better-sqlite3' || client === 'sqlite3';
28
28
  const connection = config.connection;
29
+ // Knex top-level hooks (postProcessResponse, wrapIdentifier, asyncStackTraces,
30
+ // log, debug) control per-query/row transformations and are forwarded verbatim
31
+ // from `config.hooks`. `hooks` is spread *first* so arcway's own keys
32
+ // (client/connection/useNullAsDefault) always win — user error can't reroute
33
+ // the client or break the sqlite default. The canonical use case is a
34
+ // `postProcessResponse(result, ctx)` that reads `ctx.table` (auto-stamped in
35
+ // context.js) to decode rows without per-call-site boilerplate.
36
+ const hooks = config.hooks ?? {};
29
37
 
30
38
  if (isSqlite && connection?.filename) {
31
39
  fsSync.mkdirSync(path.dirname(connection.filename), { recursive: true });
32
40
  }
33
41
 
34
42
  const db = knex({
43
+ ...hooks,
35
44
  client,
36
45
  connection,
37
46
  ...(isSqlite ? { useNullAsDefault: true } : {}),
@@ -51,7 +51,6 @@ class EventHandler {
51
51
  if (!exported) throw new Error(`Listener file at ${filePath} must have a default export`);
52
52
 
53
53
  const event = listenerPathToEvent(relativePath);
54
- if (event.startsWith('system/') || event === 'system') continue;
55
54
 
56
55
  const items = Array.isArray(exported) ? exported : [exported];
57
56
  for (let i = 0; i < items.length; i++) {
@@ -0,0 +1,58 @@
1
+ // Dev-only internal endpoints under `/_arcway/*`. A thin sync view over the
2
+ // lazy build context: manifest.json is a single JSON snapshot; `events` is an
3
+ // SSE stream of `update` events (built / invalidate / startup). Consumers
4
+ // include dev overlays, prefetchers, and any tooling that wants real-time
5
+ // build state without polling.
6
+ //
7
+ // The endpoint returns `true` if it handled the request, so the caller can
8
+ // chain it in front of the route matcher and bail out before any page
9
+ // rendering happens. Anything outside `/_arcway/*` is a pass-through.
10
+
11
+ function createArcwayDevEndpoint(lazyContext) {
12
+ return function handle(req, res) {
13
+ const method = req.method ?? 'GET';
14
+ if (method !== 'GET') return false;
15
+ const url = req.url ?? '/';
16
+ const pathname = url.split('?')[0];
17
+
18
+ if (pathname === '/_arcway/manifest.json') {
19
+ const snapshot = lazyContext.getManifestJson();
20
+ const body = JSON.stringify(snapshot);
21
+ res.writeHead(200, {
22
+ 'Content-Type': 'application/json; charset=utf-8',
23
+ 'Cache-Control': 'no-store',
24
+ 'Content-Length': Buffer.byteLength(body),
25
+ });
26
+ res.end(body);
27
+ return true;
28
+ }
29
+
30
+ if (pathname === '/_arcway/events') {
31
+ res.writeHead(200, {
32
+ 'Content-Type': 'text/event-stream; charset=utf-8',
33
+ 'Cache-Control': 'no-cache',
34
+ Connection: 'keep-alive',
35
+ // Disables proxy buffering (nginx, etc.) so events arrive in real
36
+ // time in dev. Harmless when no proxy sits in front.
37
+ 'X-Accel-Buffering': 'no',
38
+ });
39
+ // Retry directive + initial comment keeps EventSource from opening a
40
+ // second connection if the first one stalls before the first event.
41
+ res.write('retry: 2000\n\n');
42
+ res.write(': connected\n\n');
43
+
44
+ const off = lazyContext.on('update', (event) => {
45
+ if (res.writableEnded) return;
46
+ res.write(`event: update\ndata: ${JSON.stringify(event)}\n\n`);
47
+ });
48
+ req.on('close', () => {
49
+ off();
50
+ });
51
+ return true;
52
+ }
53
+
54
+ return false;
55
+ };
56
+ }
57
+
58
+ export { createArcwayDevEndpoint };
@@ -13,6 +13,12 @@ import {
13
13
  restoreMultiFileCache,
14
14
  storeMultiFileCache,
15
15
  } from './build-cache.js';
16
+ import { computeEntryChunks } from './chunk-graph.js';
17
+
18
+ // Bump when the shape produced by `extractMetadata` / the serialized cache
19
+ // payload changes in a way that would make an old cache entry decode into a
20
+ // broken manifest. Prevents a stale v1 cache from ever satisfying a v2 build.
21
+ const METADATA_VERSION = 3;
16
22
 
17
23
  // Build a deterministic plan of the hydration entries that will be written
18
24
  // into the staging dir. Computed up-front so we can derive a cache key
@@ -93,6 +99,7 @@ function computeClientCoarseKey({
93
99
  return sha256(
94
100
  JSON.stringify({
95
101
  esbuildVersion: ESBUILD_VERSION,
102
+ metadataVersion: METADATA_VERSION,
96
103
  target: target ?? '',
97
104
  minify: !!minify,
98
105
  devMode: !!devMode,
@@ -112,7 +119,8 @@ function serializeMetadata(meta) {
112
119
  navBundles: [...meta.navBundles.entries()],
113
120
  layoutNavBundles: [...meta.layoutNavBundles.entries()],
114
121
  loadingNavBundles: [...meta.loadingNavBundles.entries()],
115
- sharedChunks: meta.sharedChunks,
122
+ entryChunks: [...meta.entryChunks.entries()],
123
+ entryCssChunks: [...meta.entryCssChunks.entries()],
116
124
  };
117
125
  }
118
126
 
@@ -122,7 +130,8 @@ function deserializeMetadata(raw) {
122
130
  navBundles: new Map(raw.navBundles),
123
131
  layoutNavBundles: new Map(raw.layoutNavBundles),
124
132
  loadingNavBundles: new Map(raw.loadingNavBundles),
125
- sharedChunks: raw.sharedChunks,
133
+ entryChunks: new Map(raw.entryChunks),
134
+ entryCssChunks: new Map(raw.entryCssChunks),
126
135
  };
127
136
  }
128
137
 
@@ -205,10 +214,18 @@ async function createClientBuildContext(
205
214
  );
206
215
 
207
216
  let disposed = false;
217
+ // Files scheduled for deletion at the end of the NEXT rebuild. The
218
+ // one-generation hold gives in-flight requests from the previous HTML a
219
+ // chance to still complete against the old hashed chunk before we unlink
220
+ // it. See the commentary on `collectStaleOutputs` for why this is
221
+ // necessary in dev but not in prod.
222
+ let pendingStale = new Set();
208
223
 
209
224
  async function rebuild() {
210
225
  const result = await ctx.rebuild();
211
226
  const metadata = extractMetadata(result, plan, outDir);
227
+ await unlinkAll(pendingStale);
228
+ pendingStale = await collectStaleOutputs(result.metafile, clientDir);
212
229
  return { ...metadata, metafile: result.metafile };
213
230
  }
214
231
 
@@ -222,6 +239,62 @@ async function createClientBuildContext(
222
239
  return { rebuild, dispose };
223
240
  }
224
241
 
242
+ async function unlinkAll(absPaths) {
243
+ await Promise.all(
244
+ [...absPaths].map((p) =>
245
+ fs.unlink(p).catch((err) => {
246
+ // Missing files are expected when a subsequent rebuild reshapes the
247
+ // output set further before our GC pass runs; anything else is
248
+ // logged implicitly via the returned promise (non-fatal).
249
+ if (err?.code !== 'ENOENT') throw err;
250
+ }),
251
+ ),
252
+ );
253
+ }
254
+
255
+ // Incremental esbuild builds reuse the same `outdir` across rebuilds (the
256
+ // context requires a stable outdir), so each new hashed chunk lands alongside
257
+ // its predecessor and `chunks/` grows forever. Compute the set of files
258
+ // currently on disk but *not* in the metafile's output set — those are the
259
+ // stale artifacts to GC on the next rebuild.
260
+ //
261
+ // Prod does not need this: `buildPages()` stages into a unique dir and does
262
+ // an atomic rename, so the served clientDir always contains exactly the
263
+ // current build's outputs.
264
+ async function collectStaleOutputs(metafile, clientDir) {
265
+ const currentOutputs = new Set(
266
+ Object.keys(metafile.outputs ?? {}).map((p) => path.resolve(p)),
267
+ );
268
+ // Scope the scan to directories esbuild actually writes to. This keeps
269
+ // sibling artifacts owned by other build steps (e.g. the HMR runtime under
270
+ // `client/hmr/`) out of the GC set, since they will never appear in the
271
+ // client esbuild metafile.
272
+ const dirs = new Set();
273
+ for (const outPath of currentOutputs) dirs.add(path.dirname(outPath));
274
+
275
+ const stale = new Set();
276
+ for (const dir of dirs) {
277
+ let entries;
278
+ try {
279
+ entries = await fs.readdir(dir, { withFileTypes: true });
280
+ } catch (err) {
281
+ if (err?.code === 'ENOENT') continue;
282
+ throw err;
283
+ }
284
+ for (const entry of entries) {
285
+ if (!entry.isFile()) continue;
286
+ const abs = path.join(dir, entry.name);
287
+ if (!currentOutputs.has(abs)) stale.add(abs);
288
+ }
289
+ }
290
+ // `clientDir` is passed for future scoping hooks (e.g. whitelisting) but
291
+ // is intentionally unused today — the dirname set above already bounds the
292
+ // scan tightly and never escapes clientDir because every metafile output
293
+ // sits under it.
294
+ void clientDir;
295
+ return stale;
296
+ }
297
+
225
298
  async function tryRestoreClientFromCache({ rootDir, clientDir, coarseKey }) {
226
299
  const lookup = await lookupMultiFileCache({ rootDir, kind: 'client', coarseKey });
227
300
  if (!lookup.hit) return { hit: false, bucket: lookup.bucket };
@@ -278,6 +351,7 @@ async function buildClientBundles(
278
351
  loadings = [],
279
352
  nodeEnv = 'production',
280
353
  devMode = false,
354
+ limit = (fn) => fn(),
281
355
  ) {
282
356
  const clientDir = path.join(outDir, 'client');
283
357
  const tempDir = path.join(outDir, '.hydration-entries');
@@ -321,7 +395,7 @@ async function buildClientBundles(
321
395
  reactAlias,
322
396
  rootDir,
323
397
  });
324
- const result = await runClientEsbuild(plan, esbuildOptions, tempDir);
398
+ const result = await limit(() => runClientEsbuild(plan, esbuildOptions, tempDir));
325
399
  const metadata = extractMetadata(result, plan, outDir);
326
400
 
327
401
  if (useCache) {
@@ -346,32 +420,43 @@ function extractMetadata(result, plan, outDir) {
346
420
  const navBundles = new Map();
347
421
  const layoutNavBundles = new Map();
348
422
  const loadingNavBundles = new Map();
349
- const sharedChunks = [];
423
+ // Map<outputKey, pattern> so we can look up which page pattern owns a given
424
+ // esbuild entry output when we walk the per-entry chunk closure below.
425
+ const entryKeyToPattern = new Map();
350
426
  for (const [outputPath, meta] of Object.entries(result.metafile.outputs)) {
351
- const relPath = path.relative(outDir, outputPath).replace(/\\/g, '/');
352
427
  if (outputPath.endsWith('.map')) continue;
353
- if (relPath.startsWith('client/chunks/')) {
354
- sharedChunks.push(relPath);
355
- continue;
356
- }
357
- if (meta.entryPoint) {
358
- const absEntry = path.resolve(meta.entryPoint);
359
- const pattern =
360
- plan.entryToPattern.get(absEntry) ?? plan.entryToNavPattern.get(absEntry);
361
- if (plan.entryToPattern.has(absEntry)) {
362
- bundles.set(plan.entryToPattern.get(absEntry), relPath);
363
- } else if (plan.entryToNavPattern.has(absEntry)) {
364
- navBundles.set(plan.entryToNavPattern.get(absEntry), relPath);
365
- } else if (plan.entryToLayoutDir.has(absEntry)) {
366
- layoutNavBundles.set(plan.entryToLayoutDir.get(absEntry), relPath);
367
- } else if (plan.entryToLoadingDir.has(absEntry)) {
368
- loadingNavBundles.set(plan.entryToLoadingDir.get(absEntry), relPath);
369
- }
370
- // pattern variable intentionally unused when none of the maps match
371
- void pattern;
428
+ const relPath = path.relative(outDir, outputPath).replace(/\\/g, '/');
429
+ if (relPath.startsWith('client/chunks/')) continue;
430
+ if (!meta.entryPoint) continue;
431
+ const absEntry = path.resolve(meta.entryPoint);
432
+ if (plan.entryToPattern.has(absEntry)) {
433
+ const pattern = plan.entryToPattern.get(absEntry);
434
+ bundles.set(pattern, relPath);
435
+ entryKeyToPattern.set(outputPath, pattern);
436
+ } else if (plan.entryToNavPattern.has(absEntry)) {
437
+ navBundles.set(plan.entryToNavPattern.get(absEntry), relPath);
438
+ } else if (plan.entryToLayoutDir.has(absEntry)) {
439
+ layoutNavBundles.set(plan.entryToLayoutDir.get(absEntry), relPath);
440
+ } else if (plan.entryToLoadingDir.has(absEntry)) {
441
+ loadingNavBundles.set(plan.entryToLoadingDir.get(absEntry), relPath);
372
442
  }
373
443
  }
374
- return { bundles, navBundles, layoutNavBundles, loadingNavBundles, sharedChunks };
444
+ // Per-entry chunk closure keyed by page pattern so the manifest only lists
445
+ // the chunks a given page actually imports, rather than dumping every chunk
446
+ // from every entry on every page. Only hydration entries (mapped to a page
447
+ // pattern) participate — nav/layout/loading bundles are loaded lazily at
448
+ // navigation time and resolve their own chunks then.
449
+ const clientDir = path.join(outDir, 'client');
450
+ const perEntry = computeEntryChunks(result.metafile, clientDir);
451
+ const entryChunks = new Map();
452
+ const entryCssChunks = new Map();
453
+ for (const [entryKey, { js, css }] of perEntry.entries()) {
454
+ const pattern = entryKeyToPattern.get(entryKey);
455
+ if (!pattern) continue;
456
+ entryChunks.set(pattern, js);
457
+ entryCssChunks.set(pattern, css);
458
+ }
459
+ return { bundles, navBundles, layoutNavBundles, loadingNavBundles, entryChunks, entryCssChunks };
375
460
  }
376
461
 
377
462
  function generateHydrationEntry(componentPath, layouts = [], loadings = []) {
@@ -85,7 +85,7 @@ async function createPagesBuildContext(options) {
85
85
  ]);
86
86
 
87
87
  if (pages.length === 0) {
88
- lastResult = { pageCount: 0, outDir, manifest: { entries: [], sharedChunks: [] } };
88
+ lastResult = { pageCount: 0, outDir, manifest: { entries: [] } };
89
89
  return lastResult;
90
90
  }
91
91