@akanjs/devkit 3.0.0-beta.24 → 3.0.0-beta.26

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.
Files changed (58) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/akanApp/akanApp.host.ts +113 -23
  3. package/akanApp/devBootLatch.test.ts +39 -0
  4. package/akanApp/devBootLatch.ts +39 -0
  5. package/akanApp/devHostPolicy.test.ts +71 -1
  6. package/akanApp/devHostPolicy.ts +29 -4
  7. package/cloud/globalConfig.ts +3 -17
  8. package/csrE2e/csrE2eHarness.fixture.ts +66 -11
  9. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  10. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  11. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  12. package/fileSys.ts +16 -1
  13. package/frontendBuild/buildRouteClient.test.ts +61 -0
  14. package/frontendBuild/bundleDefine.test.ts +12 -0
  15. package/frontendBuild/bundleDefine.ts +5 -1
  16. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  17. package/frontendBuild/clientEntryDiscovery.ts +117 -70
  18. package/frontendBuild/csrArtifactBuilder.ts +3 -6
  19. package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
  20. package/frontendBuild/csrDevArtifactWriter.ts +158 -24
  21. package/frontendBuild/csrDevBundler.ts +44 -205
  22. package/frontendBuild/csrDevModuleCompiler.test.ts +89 -0
  23. package/frontendBuild/csrDevModuleCompiler.ts +56 -10
  24. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  25. package/frontendBuild/csrDevPatcher.ts +548 -0
  26. package/frontendBuild/csrDevPaths.ts +81 -1
  27. package/frontendBuild/csrDevResolver.test.ts +239 -0
  28. package/frontendBuild/csrDevResolver.ts +119 -18
  29. package/frontendBuild/csrDevTypes.ts +16 -1
  30. package/frontendBuild/csrEntryFiles.ts +24 -0
  31. package/frontendBuild/cssCompiler.ts +2 -4
  32. package/frontendBuild/devRegistryBundler.ts +192 -0
  33. package/frontendBuild/hmrWatcher.test.ts +51 -0
  34. package/frontendBuild/hmrWatcher.ts +17 -3
  35. package/frontendBuild/index.ts +3 -0
  36. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  37. package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
  38. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  39. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  40. package/frontendBuild/routeClientBuilder.ts +79 -18
  41. package/frontendBuild/serverGraphFile.test.ts +77 -0
  42. package/frontendBuild/serverGraphFile.ts +93 -0
  43. package/frontendBuild/ssrDevBundler.ts +85 -0
  44. package/incrementalBuilder/buildBatch.proc.ts +94 -16
  45. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  46. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  47. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  48. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  49. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  50. package/incrementalBuilder/incrementalBuilder.host.test.ts +35 -1
  51. package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
  52. package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
  53. package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
  54. package/integration/devStability.integration.test.ts +25 -7
  55. package/package.json +2 -2
  56. package/transforms/rscUseClientTransform.ts +7 -0
  57. package/transforms/transforms.test.ts +13 -0
  58. package/transforms/useClientBundlePlugin.ts +4 -1
@@ -1,3 +1,4 @@
1
+ import { rm } from "node:fs/promises";
1
2
  import path from "node:path";
2
3
  // Module paths, never a barrel: the devkit and `frontendBuild` barrels would hold tailwindcss (~40MB), ssh2, ink
3
4
  // and the cloud stack for the whole dev session (`entryModuleGraph.test.ts` enforces it).
@@ -8,10 +9,14 @@ import { AppExecutor, type PageRoot, WorkspaceExecutor } from "@akanjs/devkit/ex
8
9
  import { AutoImportSync } from "@akanjs/devkit/frontendBuild/autoImportSync";
9
10
  import type { ClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientBuildTypes";
10
11
  import { GraphClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientEntryDiscovery";
12
+ import { CsrDevBundler } from "@akanjs/devkit/frontendBuild/csrDevBundler";
13
+ import { CsrDevPatcher } from "@akanjs/devkit/frontendBuild/csrDevPatcher";
11
14
  import { DevChangePlanner } from "@akanjs/devkit/frontendBuild/devChangePlanner";
12
15
  import { DevGeneratedIndexSync } from "@akanjs/devkit/frontendBuild/devGeneratedIndexSync";
13
16
  import { HmrWatcher } from "@akanjs/devkit/frontendBuild/hmrWatcher";
14
17
  import { RouteClientBuilder } from "@akanjs/devkit/frontendBuild/routeClientBuilder";
18
+ import { ServerGraphFile } from "@akanjs/devkit/frontendBuild/serverGraphFile";
19
+ import { SsrDevBundler } from "@akanjs/devkit/frontendBuild/ssrDevBundler";
15
20
  import { WatchRootResolver } from "@akanjs/devkit/frontendBuild/watchRootResolver";
16
21
  import { Logger } from "akanjs/common";
17
22
  import type {
@@ -23,10 +28,13 @@ import type {
23
28
  BuildPhase,
24
29
  BuildRouteResultPayload,
25
30
  ChangeBatch,
31
+ HmrTrace,
26
32
  } from "akanjs/server";
33
+ import { CSR_DEV_PATCHING_MARKER } from "akanjs/server/hmr/csrDevManifest";
27
34
  import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
28
35
  import { BuildBatchRunner } from "./buildBatchRunner";
29
36
  import { BuilderChannel } from "./builderChannel";
37
+ import { type BatchJob, BuilderWorkQueue, type DiscoveryJob } from "./builderWorkQueue";
30
38
  import { prepareDevWatchBatch } from "./devWatchBatch";
31
39
 
32
40
  interface IncrementalBuilderOptions {
@@ -39,7 +47,7 @@ interface IncrementalBuilderOptions {
39
47
  }
40
48
 
41
49
  type IncrementalBuilderBootDeps = Pick<IncrementalBuilderOptions, "artifact" | "optimizedFonts" | "discovery">;
42
- type BatchWork = Pick<BuildBatchRequest, "generation" | "needs" | "changedFiles">;
50
+ type BatchWork = Pick<BuildBatchRequest, "generation" | "needs" | "changedFiles" | "trace">;
43
51
 
44
52
  class IncrementalBuilder {
45
53
  #logger = new Logger("IncrementalBuilder");
@@ -55,8 +63,43 @@ class IncrementalBuilder {
55
63
  #autoImportSync: AutoImportSync;
56
64
  #watcher: HmrWatcher | null = null;
57
65
  #generation = 0;
66
+ #discoveredThrough = 0;
58
67
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
59
- #workQueue: Promise<void> = Promise.resolve();
68
+ #csrBundler: CsrDevBundler;
69
+ /** Null when `AKAN_DEV_CSR_PATCHER=off`: every CSR save then goes to a build worker, as before the patcher. */
70
+ #patcher: CsrDevPatcher | null;
71
+ /** A worker's full CSR build (arming, re-arming) that a patch must not race for the csr-dev directory. */
72
+ #csrGate: Promise<void> = Promise.resolve();
73
+ #csrArming = false;
74
+ #csrArms = 0;
75
+ #crashRetryTimer: ReturnType<typeof setTimeout> | null = null;
76
+ readonly #crashRetryEntries = new Set<string>();
77
+ #csrWasActive = false;
78
+ #csrArmSucceeded = false;
79
+ /** Where SSR pages load their client code from in dev; route builds bundle only its server half. */
80
+ #ssrBundler: SsrDevBundler;
81
+ /** Null when `AKAN_DEV_CSR_PATCHER=off`: every registry save then goes to a build worker. */
82
+ #ssrPatcher: CsrDevPatcher | null;
83
+ //* Serializes everything that writes ssr-dev: a save's patch (fast lane), a route build adding the entries it names,
84
+ //* a queued worker batch with the ssr need, and the boot build. Never held while awaiting the slow lane.
85
+ #ssrLock: Promise<void> = Promise.resolve();
86
+ /** How often each reason sent a registry save to a build worker this session: the resident patcher's miss rate. */
87
+ readonly #delegations = new Map<string, number>();
88
+ /** The boot build of the SSR registry while it runs; it holds every entry the routes reach once it lands. */
89
+ #ssrArming: Promise<void> | null = null;
90
+ //* A registry build that failed on the user's code fails again until the code changes, so route builds do not retry
91
+ //* it; the next attempt that touches the registry (a save's patch, a worker batch) settles it. A worker that died
92
+ //* before reporting (an OOM kill) route builds do retry, at most every `#crashRetryMs`.
93
+ #ssrBroken = false;
94
+ #ssrCrashedAt: number | null = null;
95
+ static readonly #crashRetryMs = 10_000;
96
+ /** Arming and the route entries deferred behind it run beside both lanes; a recycle waits for them too. */
97
+ readonly #beside = new Set<Promise<void>>();
98
+ #barrelFailed = false;
99
+ //* Two lanes: a save's codegen and CSR patch in the fast one, which the watcher waits for; build workers, route
100
+ //* builds and discovery in the slow one, which folds queued batches. A save no longer waits behind pages and css.
101
+ #fastQueue: Promise<void> = Promise.resolve();
102
+ #workQueue: BuilderWorkQueue;
60
103
  #inFlight = 0;
61
104
  #workCount = 0;
62
105
  #shuttingDown = false;
@@ -70,6 +113,7 @@ class IncrementalBuilder {
70
113
  this.#optimizedFonts = options.optimizedFonts;
71
114
  this.#discovery = options.discovery;
72
115
  this.#generation = options.initialGeneration ?? 0;
116
+ this.#discoveredThrough = this.#generation;
73
117
  this.#batchRunner = new BuildBatchRunner({
74
118
  workspaceRoot: options.app.workspace.workspaceRoot,
75
119
  cwd: options.app.cwdPath,
@@ -77,6 +121,20 @@ class IncrementalBuilder {
77
121
  this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
78
122
  this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
79
123
  this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
124
+ this.#csrBundler = new CsrDevBundler(options.app);
125
+ this.#patcher =
126
+ process.env.AKAN_DEV_CSR_PATCHER === "off" ? null : new CsrDevPatcher(this.#csrBundler, { resident: true });
127
+ this.#ssrBundler = new SsrDevBundler(options.app, { discovery: () => this.#discovery });
128
+ this.#ssrPatcher =
129
+ process.env.AKAN_DEV_CSR_PATCHER === "off" ? null : new CsrDevPatcher(this.#ssrBundler, { resident: true });
130
+ this.#workQueue = new BuilderWorkQueue({
131
+ runBatch: async (batch) => await this.#runQueuedBatch(batch),
132
+ onSettled: (label, ms) => {
133
+ this.#workCount += 1;
134
+ this.#logger.verbose(`[work-queue] ${label} finished in ${ms}ms`);
135
+ this.#reportMetrics();
136
+ },
137
+ });
80
138
  }
81
139
 
82
140
  get #artifactDir() {
@@ -85,12 +143,13 @@ class IncrementalBuilder {
85
143
 
86
144
  // The reply is part of the work item on purpose: `shutdown` drains the queue, so "drained" must mean "answered".
87
145
  async handleBuildRoute(msg: BuilderReq): Promise<void> {
88
- await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
146
+ await this.#workQueue.enqueue(`build-route:${msg.routeId}`, async () =>
89
147
  BuilderChannel.send(await this.#handleBuildRoute(msg)),
90
148
  );
91
149
  }
92
150
 
93
151
  async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
152
+ const seenGeneration = this.#discoveredThrough;
94
153
  try {
95
154
  const delta = await new RouteClientBuilder({
96
155
  app: this.#app,
@@ -100,9 +159,13 @@ class IncrementalBuilder {
100
159
  artifact: this.#artifact,
101
160
  knownEntries: new Set<string>(msg.knownEntries),
102
161
  discovery: this.#discovery,
162
+ browser: "registry",
103
163
  }).build();
164
+ await this.#ensureSsrEntries(delta.registryEntries ?? []);
104
165
  this.#logger.verbose(`build-route ok routeId=${msg.routeId} newEntries=${delta.newEntries.length}`);
105
- this.#sendBuildStatus("route", { generation: msg.generation, ok: true, files: msg.seeds });
166
+ //? The builder's generation, not the route cache's: the cache's does not move when a fixed file maps to no entry,
167
+ //? so the rebuild's ok would carry the failure's own number.
168
+ this.#sendBuildStatus("route", { generation: this.#generation, ok: true, files: msg.seeds, scope: msg.routeId });
106
169
  return {
107
170
  type: "build-route-res",
108
171
  id: msg.id,
@@ -116,48 +179,67 @@ class IncrementalBuilder {
116
179
  clientDepsByEntry: delta.clientDepsByEntry,
117
180
  routeId: msg.routeId,
118
181
  generation: msg.generation,
182
+ seenGeneration,
119
183
  } as BuildRouteResultPayload,
120
184
  };
121
185
  } catch (err) {
122
186
  const errMsg = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
123
187
  this.#logger.error(`build-route failed routeId=${msg.routeId}: ${errMsg}`);
124
- this.#sendBuildStatus("route", { generation: msg.generation, ok: false, files: msg.seeds, message: errMsg });
188
+ this.#sendBuildStatus("route", {
189
+ generation: this.#generation,
190
+ ok: false,
191
+ files: msg.seeds,
192
+ message: errMsg,
193
+ scope: msg.routeId,
194
+ });
125
195
  return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
126
196
  }
127
197
  }
128
198
  #sendBuildStatus(
129
199
  phase: BuildPhase,
130
- { generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
200
+ {
201
+ generation,
202
+ ok,
203
+ files,
204
+ message,
205
+ scope,
206
+ }: { generation?: number; ok: boolean; files?: string[]; message?: string; scope?: string },
131
207
  ): void {
132
208
  if (typeof generation !== "number") return;
133
209
  BuilderChannel.emit({
134
210
  type: "build-status",
135
- data: {
136
- generation,
137
- phase,
138
- ok,
139
- files: files ?? [],
140
- message,
141
- },
211
+ data: { generation, phase, ok, files: files ?? [], message, ...(scope !== undefined ? { scope } : {}) },
142
212
  });
143
213
  }
144
- async #enqueueWork<T>(label: string, fn: () => Promise<T>): Promise<T> {
214
+ async #enqueueFast<T>(label: string, fn: () => Promise<T>): Promise<T> {
145
215
  const started = Date.now();
146
216
  this.#inFlight += 1;
147
- const run = this.#workQueue.then(fn, fn);
148
- this.#workQueue = run.then(() => undefined).catch(() => undefined);
217
+ const run = this.#fastQueue.then(fn, fn);
218
+ this.#fastQueue = run.then(() => undefined).catch(() => undefined);
149
219
  try {
150
220
  return await run;
151
221
  } finally {
152
222
  this.#inFlight -= 1;
153
223
  this.#workCount += 1;
154
- this.#logger.verbose(`[work-queue] ${label} finished in ${Date.now() - started}ms`);
224
+ this.#logger.verbose(`[fast-queue] ${label} finished in ${Date.now() - started}ms`);
155
225
  this.#reportMetrics();
156
226
  }
157
227
  }
158
228
 
159
229
  get #idle(): boolean {
160
- return this.#inFlight === 0 && this.#cssRebuildTimer === null;
230
+ return this.#inFlight === 0 && this.#workQueue.size === 0 && this.#cssRebuildTimer === null;
231
+ }
232
+
233
+ // Callers catch their own errors: the promise handed in never rejects.
234
+ #runBeside(work: Promise<void>): Promise<void> {
235
+ this.#inFlight += 1;
236
+ const tracked: Promise<void> = work.finally(() => {
237
+ this.#inFlight -= 1;
238
+ this.#beside.delete(tracked);
239
+ this.#reportMetrics();
240
+ });
241
+ this.#beside.add(tracked);
242
+ return tracked;
161
243
  }
162
244
 
163
245
  // Idle only: the host recycles on these metrics, and a recycle decided mid-work would truncate it or race the drain.
@@ -184,8 +266,15 @@ class IncrementalBuilder {
184
266
  this.#cssRebuildTimer = null;
185
267
  this.#pendingCssRebuild = null;
186
268
  }
187
- await this.#workQueue.catch(() => undefined);
269
+ // The fast lane first: a save it is still handling may queue a batch in the slow one.
270
+ await this.#fastQueue.catch(() => undefined);
271
+ await this.#workQueue.drain();
188
272
  await this.#cssRebuildQueue.catch(() => undefined);
273
+ while (this.#beside.size > 0) await Promise.all([...this.#beside]);
274
+ if (this.#delegations.size > 0)
275
+ this.#logger.verbose(
276
+ `registry saves handed to build workers this session: ${[...this.#delegations].map(([reason, count]) => `${reason}=${count}`).join(", ")}`,
277
+ );
189
278
  // Drained queues are not delivered results: `process.exit` drops unflushed ipc writes (a relayed `css-updated`).
190
279
  const flushed = await BuilderChannel.drain();
191
280
  this.#logger.debug(
@@ -264,7 +353,7 @@ class IncrementalBuilder {
264
353
  roots,
265
354
  logger: this.#logger,
266
355
  onBatch: async (batch: ChangeBatch) => {
267
- await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(batch));
356
+ await this.#enqueueFast("hmr-batch", async () => this.#handleWatchBatch(batch));
268
357
  },
269
358
  });
270
359
  await watcher.start();
@@ -276,6 +365,7 @@ class IncrementalBuilder {
276
365
  const rawKinds = new Set(batch.kinds);
277
366
  if (rawKinds.size === 0) return;
278
367
  const generation = ++this.#generation;
368
+ const trace = { ...batch.trace, batchAt: Date.now() };
279
369
  //* Auto-import edits touch only this batch's files, so they rebuild in this same generation.
280
370
  const [autoImport, indexSync] = await CodegenLock.run(
281
371
  this.#app.workspace.workspaceRoot,
@@ -301,21 +391,24 @@ class IncrementalBuilder {
301
391
  );
302
392
  for (const error of indexSync.errors) this.#logger.error(error);
303
393
 
304
- if (kinds.includes("code")) {
305
- const started = Date.now();
306
- if (kinds.includes("config")) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
307
- else this.#discovery.invalidate?.(files);
308
- this.#logger.verbose(
309
- `client-entry-discovery ${kinds.includes("config") ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`,
310
- );
311
- }
394
+ // Invalidated in the slow lane, under the route builds that read it; a registry check reading it beside them caches
395
+ // nothing a walk began before the invalidation (the discovery's epoch).
396
+ const discovery = kinds.includes("code") ? { files, refresh: kinds.includes("config"), generation } : undefined;
312
397
 
313
398
  if (hasSyncErrors) {
399
+ this.#barrelFailed = true;
314
400
  this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
315
401
  BuilderChannel.emit(event);
402
+ if (discovery)
403
+ void this.#workQueue
404
+ .enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
405
+ .catch(this.#slowLaneFailed("discovery"));
316
406
  return;
317
407
  }
318
- if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
408
+ // After a failure too, where nothing changed on disk: the host re-sends a phase still failing to every new tab.
409
+ if (indexSync.changedFiles.length > 0 || this.#barrelFailed)
410
+ this.#sendBuildStatus("barrel", { generation, ok: true, files });
411
+ this.#barrelFailed = false;
319
412
 
320
413
  // Server-only generations skip the client: a fresh pages buildId would rsc-refresh browsers for no visible change.
321
414
  const rebuildClient = devPlan.actions.includes("rebuild-client");
@@ -332,47 +425,392 @@ class IncrementalBuilder {
332
425
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
333
426
  }
334
427
 
335
- const needs: BuildBatchNeed[] = [];
428
+ BuilderChannel.emit(event);
429
+
336
430
  if (kinds.includes("code") && rebuildClient) {
337
- if (this.#csrActive) needs.push("csr");
338
- else
431
+ // Css rides the pages batch rather than its own debounce: the slow lane folds a burst of saves into one batch.
432
+ const needs: BuildBatchNeed[] = ["pages", "css"];
433
+ if (!this.#csrActive)
339
434
  this.#logger.verbose(
340
435
  `csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
341
436
  );
342
- needs.push("pages");
343
- // In this batch rather than debounced: the queue already serializes generations, and a second worker per save
344
- // costs more than coalescing would save.
345
- needs.push("css");
437
+ //? One after the other, since two compiles side by side slow both. CSR goes first unless its build is being armed:
438
+ //? a CSR patch then waits behind #csrGate, which SSR tabs must not.
439
+ const patchCsr = async () => (this.#csrActive ? await this.#patchCsr(generation, files, trace) : false);
440
+ const patchSsr = async () => await this.#patchSsr(generation, files, trace);
441
+ let csrToWorker: boolean;
442
+ let ssrToWorker: boolean;
443
+ if (this.#csrArming) {
444
+ ssrToWorker = await patchSsr();
445
+ csrToWorker = await patchCsr();
446
+ } else {
447
+ csrToWorker = await patchCsr();
448
+ ssrToWorker = await patchSsr();
449
+ }
450
+ if (csrToWorker) needs.unshift("csr");
451
+ if (ssrToWorker) needs.unshift("ssr");
452
+ const batch: BatchJob = { generation, needs, changedFiles: files, trace, ...(discovery ? { discovery } : {}) };
453
+ // A worker's registry build rewrites the directory its patcher reads, so the next save waits for it.
454
+ if (needs.includes("csr") || needs.includes("ssr")) await this.#workQueue.enqueueBatch(batch);
455
+ else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
456
+ return;
346
457
  }
347
-
348
- BuilderChannel.emit(event);
349
-
350
- if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
458
+ if (discovery)
459
+ void this.#workQueue
460
+ .enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
461
+ .catch(this.#slowLaneFailed("discovery"));
351
462
  // Css-only batches keep the debounce: they arrive in bursts while a stylesheet is edited.
352
- else if (kinds.includes("css")) {
463
+ if (kinds.includes("css")) {
353
464
  this.scheduleCssRebuild({ generation, changedFiles: files });
354
465
  this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
355
466
  }
356
467
  }
357
468
 
469
+ // True when the save has to go to a build worker: the patcher handed it back, or threw (the worker reports why).
470
+ async #patchCsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
471
+ const patcher = this.#patcher;
472
+ if (!patcher) return true;
473
+ await this.#csrGate;
474
+ // The arming build this save waited for failed: CSR is off again, and the next request arms it anew.
475
+ if (!this.#csrActive) return false;
476
+ const started = Date.now();
477
+ const marker = path.join(this.#csrBundler.outDir, CSR_DEV_PATCHING_MARKER);
478
+ await Bun.write(marker, String(process.pid));
479
+ try {
480
+ const result = await patcher.update(files, {
481
+ announce: (update) => {
482
+ const now = Date.now();
483
+ BuilderChannel.emit({
484
+ type: "csr-updated",
485
+ data: {
486
+ generation: update.generation,
487
+ mode: "registry",
488
+ reload: update.reload,
489
+ reason: update.reason,
490
+ patchUrl: update.patchUrl,
491
+ changedIds: update.changedIds,
492
+ trace: { ...trace, patchAt: now, sentAt: now },
493
+ },
494
+ });
495
+ },
496
+ });
497
+ if (result.kind === "delegate") {
498
+ this.#logger.verbose(
499
+ `csr-patch handed to a build worker (${this.#countDelegation("csr", result.reason)}): ${result.reason}`,
500
+ );
501
+ return true;
502
+ }
503
+ this.#sendBuildStatus("csr", { generation, ok: true, files });
504
+ this.#logger.verbose(
505
+ result.kind === "unchanged"
506
+ ? `csr-patch unchanged (${Date.now() - started}ms)`
507
+ : `csr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
508
+ );
509
+ return false;
510
+ } catch (err) {
511
+ const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
512
+ if (IncrementalBuilder.#isCompileError(err)) {
513
+ this.#sendBuildStatus("csr", { generation, ok: false, files, message });
514
+ return false;
515
+ }
516
+ this.#logger.verbose(`csr-patch threw; a build worker takes this save: ${message}`);
517
+ patcher.forget();
518
+ return true;
519
+ } finally {
520
+ await rm(marker, { force: true });
521
+ }
522
+ }
523
+
524
+ // True when the save has to go to a build worker, as for CSR.
525
+ async #patchSsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
526
+ const patcher = this.#ssrPatcher;
527
+ if (!patcher) return true;
528
+ const hold = await ServerGraphFile.touches(await ServerGraphFile.read(this.#artifactDir), files, (file) =>
529
+ ServerGraphFile.clientExportsOf(file),
530
+ );
531
+ //? Ahead of this save's own discovery job in the slow lane: which client entries a page still names decides which
532
+ //? pending roots this patch keeps.
533
+ this.#discovery.invalidate?.(files);
534
+ return await this.#withSsrLock(
535
+ async () => await this.#runSsrPatcher(patcher, files, { trace, hold, batchGeneration: generation }),
536
+ );
537
+ }
538
+
539
+ //* Adds the entries a route build names to the registry, since the tab requires them by id. A registry that cannot
540
+ //* take them fails no route build: the page answers, and its tab waits for the registry while the overlay says why.
541
+ async #ensureSsrEntries(entries: string[], { crashRetry = false }: { crashRetry?: boolean } = {}): Promise<void> {
542
+ if (entries.length === 0) return;
543
+ //? The boot build takes every entry the routes reach, so the first page answers now and its tab waits for the
544
+ //? registry (boot.json) instead; whatever that build missed is added right after it lands.
545
+ const arming = this.#ssrArming;
546
+ if (arming) {
547
+ void this.#runBeside(
548
+ arming
549
+ .then(async () => await this.#ensureSsrEntries(entries, { crashRetry }))
550
+ .catch(this.#slowLaneFailed("ssr-ensure")),
551
+ );
552
+ return;
553
+ }
554
+ await this.#withSsrLock(async () => {
555
+ const patcher = this.#ssrPatcher;
556
+ // The builder's generation, not the request's: its build-status joins the saves' `ssr` ones.
557
+ const generation = this.#generation;
558
+ if (
559
+ patcher &&
560
+ !(await this.#runSsrPatcher(patcher, [], { roots: entries, onlyRoots: true, batchGeneration: generation }))
561
+ )
562
+ return;
563
+ //? Read, not compiled: with the patcher off, the save's own worker batch already built every entry.
564
+ if (!patcher && (await this.#ssrBundler.holds(entries))) return;
565
+ //? Not retried per route build: the save that fixes the error rebuilds it, where each route build retrying held
566
+ //? the SSR lock that save's patch waits on.
567
+ if (this.#ssrBroken) return;
568
+ if (this.#ssrCrashedAt !== null && Date.now() - this.#ssrCrashedAt < IncrementalBuilder.#crashRetryMs) {
569
+ //? Handed over just now with nothing run for them: the retry must find them to hand over again.
570
+ patcher?.releaseHandedOver();
571
+ this.#retryAfterCrash(this.#ssrCrashedAt, entries);
572
+ return;
573
+ }
574
+ await this.#runSsrWorker(generation).catch((err: unknown) => {
575
+ this.#logger.error(
576
+ `ssr-registry could not take a route's entries; the save that fixes it rebuilds it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
577
+ );
578
+ });
579
+ //? A worker that died took these entries with it and left them pending nowhere: they are tried once more after the
580
+ //? crash window, but a retry that crashes again waits for the next route build or save.
581
+ if (this.#ssrCrashedAt !== null && !crashRetry) this.#retryAfterCrash(this.#ssrCrashedAt, entries);
582
+ });
583
+ }
584
+
585
+ //? Once the crash window closes, for the entries skipped in it: later route builds may name none (the first one
586
+ //? announced every entry to the route cache), so without this a tab could wait on boot.json until the next save.
587
+ #retryAfterCrash(crashedAt: number, entries: string[]): void {
588
+ for (const entry of entries) this.#crashRetryEntries.add(entry);
589
+ if (this.#crashRetryTimer) return;
590
+ const timer = setTimeout(
591
+ () => {
592
+ this.#crashRetryTimer = null;
593
+ const retried = [...this.#crashRetryEntries];
594
+ this.#crashRetryEntries.clear();
595
+ if (this.shuttingDown || retried.length === 0) return;
596
+ void this.#runBeside(this.#ensureSsrEntries(retried, { crashRetry: true })).catch(
597
+ this.#slowLaneFailed("ssr-crash-retry"),
598
+ );
599
+ },
600
+ Math.max(0, crashedAt + IncrementalBuilder.#crashRetryMs - Date.now()),
601
+ );
602
+ timer.unref?.();
603
+ this.#crashRetryTimer = timer;
604
+ }
605
+
606
+ //* The boot build of the SSR registry runs beside the slow lane, holding only the SSR lock: the first page's route
607
+ //* build bundles meanwhile instead of queueing behind it (on apps/akan it cost the first page about a second).
608
+ async armSsrRegistry(): Promise<void> {
609
+ //? `AKAN_DEV_SSR_ARM_DELAY_MS` is a test hook: an E2E has a tab reconnect before the boot build has run.
610
+ const delayMs = Number(process.env.AKAN_DEV_SSR_ARM_DELAY_MS);
611
+ if (Number.isInteger(delayMs) && delayMs > 0) await Bun.sleep(delayMs);
612
+ const arm = async () =>
613
+ await this.#withSsrLock(async () => {
614
+ const generation = this.#generation;
615
+ if (this.#ssrPatcher && !(await this.#runSsrPatcher(this.#ssrPatcher, [], { batchGeneration: generation })))
616
+ return;
617
+ await this.#runSsrWorker(generation);
618
+ }).catch((err: unknown) => {
619
+ this.#logger.error(
620
+ `ssr-registry boot build failed; the next save retries it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
621
+ );
622
+ });
623
+ const arming = this.#runBeside(
624
+ (async () => {
625
+ await arm();
626
+ //? A boot build whose worker was killed (a small container's OOM) is tried once more before `boot-armed` lets
627
+ //? the next app boot: retried after that, the two biggest workers of a boot would run side by side.
628
+ const crashedAt = this.#ssrCrashedAt;
629
+ if (crashedAt === null) return;
630
+ while (!this.shuttingDown && Date.now() < crashedAt + IncrementalBuilder.#crashRetryMs) await Bun.sleep(250);
631
+ if (!this.shuttingDown) await arm();
632
+ })(),
633
+ );
634
+ this.#ssrArming = arming;
635
+ await arming;
636
+ this.#ssrArming = null;
637
+ }
638
+
639
+ //? A save the user broke fails the same way in a worker, so it is reported here and the patcher keeps its state: the
640
+ //? modules it could not compile stay pending in the graph and the fixing save compiles them.
641
+ static #isCompileError(error: unknown): boolean {
642
+ return error instanceof AggregateError;
643
+ }
644
+
645
+ // Voided so the fast lane need not wait; unhandled, a rejection would exit the builder and drop the watcher with it.
646
+ #slowLaneFailed(label: string) {
647
+ return (error: unknown) =>
648
+ this.#logger.error(
649
+ `${label} failed in the slow lane: ${ApplicationBuildReporter.formatError(error, this.#app.workspace.workspaceRoot)}`,
650
+ );
651
+ }
652
+
653
+ // A reason's own detail (which import) follows its first colon, so saves of one kind count together.
654
+ #countDelegation(registry: "csr" | "ssr", reason: string): string {
655
+ const key = `${registry}:${reason.split(":")[0]}`;
656
+ const count = (this.#delegations.get(key) ?? 0) + 1;
657
+ this.#delegations.set(key, count);
658
+ return `#${count} for this reason`;
659
+ }
660
+
661
+ async #withSsrLock<T>(fn: () => Promise<T>): Promise<T> {
662
+ const run = this.#ssrLock.then(fn, fn);
663
+ this.#ssrLock = run.then(
664
+ () => undefined,
665
+ () => undefined,
666
+ );
667
+ return await run;
668
+ }
669
+
670
+ // Runs the worker directly, under the SSR lock its callers hold: queueing it could wait on a batch that waits on them.
671
+ async #runSsrWorker(generation: number): Promise<void> {
672
+ let result: BuildBatchResult;
673
+ try {
674
+ result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
675
+ } finally {
676
+ this.#ssrPatcher?.forget();
677
+ }
678
+ this.#noteSsrWorker(result);
679
+ if (result.errors.ssr) throw new Error(result.errors.ssr);
680
+ }
681
+
682
+ #noteSsrWorker(result: BuildBatchResult): void {
683
+ const crashed = !!result.crashed && (result.crashedNeeds ?? ["ssr"]).includes("ssr");
684
+ this.#ssrBroken = !!result.errors.ssr && !crashed;
685
+ this.#ssrCrashedAt = crashed ? Date.now() : null;
686
+ }
687
+
688
+ async #runSsrPatcher(
689
+ patcher: CsrDevPatcher,
690
+ files: string[],
691
+ {
692
+ roots,
693
+ onlyRoots,
694
+ trace,
695
+ hold,
696
+ batchGeneration,
697
+ }: { roots?: string[]; onlyRoots?: boolean; trace?: HmrTrace; hold?: boolean; batchGeneration?: number } = {},
698
+ ): Promise<boolean> {
699
+ const started = Date.now();
700
+ const marker = path.join(this.#ssrBundler.outDir, CSR_DEV_PATCHING_MARKER);
701
+ await Bun.write(marker, String(process.pid));
702
+ try {
703
+ const result = await patcher.update(files, {
704
+ roots,
705
+ onlyRoots,
706
+ announce: (update) => {
707
+ const now = Date.now();
708
+ BuilderChannel.emit({
709
+ type: "ssr-updated",
710
+ data: {
711
+ generation: update.generation,
712
+ reload: update.reload,
713
+ reason: update.reason,
714
+ patchUrl: update.patchUrl,
715
+ changedIds: update.changedIds,
716
+ ...(trace ? { trace: { ...trace, patchAt: now, sentAt: now } } : {}),
717
+ ...(hold ? { hold } : {}),
718
+ ...(batchGeneration !== undefined ? { batchGeneration } : {}),
719
+ ...(update.epoch !== undefined ? { epoch: update.epoch } : {}),
720
+ ...(update.first ? { first: true } : {}),
721
+ },
722
+ });
723
+ },
724
+ });
725
+ if (result.kind === "delegate") {
726
+ this.#logger.verbose(
727
+ `ssr-patch handed to a build worker (${this.#countDelegation("ssr", result.reason)}): ${result.reason}`,
728
+ );
729
+ return true;
730
+ }
731
+ //? A route build's check reports only a failure: it compiles the roots the registry lacks and nothing else, so
732
+ //? its ok, of the builder's current generation, would read as the fix of a save that broke something else.
733
+ if (!onlyRoots) {
734
+ this.#ssrBroken = false;
735
+ this.#ssrCrashedAt = null;
736
+ this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: true, files });
737
+ }
738
+ this.#logger.verbose(
739
+ result.kind === "unchanged"
740
+ ? `ssr-patch unchanged (${Date.now() - started}ms)`
741
+ : `ssr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
742
+ );
743
+ return false;
744
+ } catch (err) {
745
+ const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
746
+ if (IncrementalBuilder.#isCompileError(err)) {
747
+ this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: false, files, message });
748
+ return false;
749
+ }
750
+ this.#logger.verbose(`ssr-patch threw; a build worker takes it: ${message}`);
751
+ patcher.forget();
752
+ return true;
753
+ } finally {
754
+ await rm(marker, { force: true });
755
+ }
756
+ }
757
+
758
+ async #runQueuedBatch({ discovery, ...work }: BatchJob): Promise<void> {
759
+ if (discovery) await this.#refreshDiscovery(discovery);
760
+ //? The slow lane runs in order, so every batch up to this one has had its discovery job.
761
+ this.#discoveredThrough = Math.max(this.#discoveredThrough, work.generation);
762
+ // A worker writing ssr-dev must not overlap the boot build, which no longer runs in this lane.
763
+ //? Forgotten inside the lock: a route build's check waiting on it would patch the old state over the worker's.
764
+ try {
765
+ if (work.needs.includes("ssr"))
766
+ await this.#withSsrLock(async () => {
767
+ try {
768
+ this.#noteSsrWorker(await this.#runBatch(work));
769
+ } finally {
770
+ this.#ssrPatcher?.forget();
771
+ }
772
+ });
773
+ else await this.#runBatch(work);
774
+ } finally {
775
+ if (work.needs.includes("csr")) this.#patcher?.forget();
776
+ }
777
+ }
778
+
779
+ async #refreshDiscovery({ files, refresh, generation }: DiscoveryJob): Promise<void> {
780
+ const started = Date.now();
781
+ if (refresh) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
782
+ else this.#discovery.invalidate?.(files);
783
+ this.#discoveredThrough = Math.max(this.#discoveredThrough, generation);
784
+ this.#logger.verbose(`client-entry-discovery ${refresh ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`);
785
+ }
786
+
358
787
  async #runBatch(work: BatchWork): Promise<BuildBatchResult> {
359
788
  const { generation, needs, changedFiles } = work;
360
789
  const started = Date.now();
361
790
  const result = await this.#batchRunner.run(await this.#batchRequest(work), (msg) => BuilderChannel.emit(msg));
362
791
  if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
363
792
  if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
364
- // A crashed worker streamed no build-status, so each need goes red here instead of looking silently successful.
793
+ // A need the worker died before reporting goes red here instead of looking silently successful.
365
794
  if (result.crashed) {
795
+ const crashedNeeds = result.crashedNeeds ?? needs;
796
+ //? A worker that died wrote no failure for the roots handed to it: they must not wait on one that never comes.
797
+ if (crashedNeeds.includes("csr")) this.#patcher?.releaseHandedOver();
798
+ if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
366
799
  // `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
367
- for (const need of needs)
800
+ for (const need of crashedNeeds)
368
801
  if (need !== "base")
369
- this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
802
+ this.#sendBuildStatus(need, {
803
+ generation,
804
+ ok: false,
805
+ files: changedFiles,
806
+ message: `${result.errors[need] ?? "the build worker crashed"}; the next save builds it again`,
807
+ });
370
808
  }
371
809
  if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
372
810
  return result;
373
811
  }
374
812
 
375
- async #batchRequest({ generation, needs, changedFiles }: BatchWork): Promise<BuildBatchRequest> {
813
+ async #batchRequest({ generation, needs, changedFiles, trace }: BatchWork): Promise<BuildBatchRequest> {
376
814
  return {
377
815
  appName: this.#app.name,
378
816
  workspaceRoot: this.#app.workspace.workspaceRoot,
@@ -384,6 +822,7 @@ class IncrementalBuilder {
384
822
  optimizedFonts: this.#optimizedFonts,
385
823
  cssAssets: this.#artifact.cssAssets ?? null,
386
824
  artifactDir: path.resolve(this.#artifactDir),
825
+ ...(trace ? { trace } : {}),
387
826
  };
388
827
  }
389
828
 
@@ -396,7 +835,7 @@ class IncrementalBuilder {
396
835
  // The backend still serves the last-good bundle after a degraded boot; push fresh pages/css without another edit.
397
836
  async announceRecoveredState(changedFiles: string[]): Promise<void> {
398
837
  const generation = ++this.#generation;
399
- await this.#enqueueWork("boot-recovered", async () => {
838
+ await this.#workQueue.enqueue("boot-recovered", async () => {
400
839
  await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
401
840
  });
402
841
  }
@@ -435,22 +874,55 @@ class IncrementalBuilder {
435
874
 
436
875
  // On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
437
876
  async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
438
- await this.#enqueueWork("build-csr", async (): Promise<void> => {
877
+ //? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
878
+ this.#beginCsrArm();
879
+ this.#csrActive = true;
880
+ const armed = this.#workQueue.enqueue("build-csr", async (): Promise<void> => {
439
881
  const started = Date.now();
440
- // Not relayed: an on-demand CSR build is request/response, and its error travels in the response.
441
- const result = await this.#batchRunner.run(
442
- await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
443
- );
444
- const error = result.errors.csr;
882
+ let error: string | undefined;
883
+ try {
884
+ // Not relayed: an on-demand CSR build is request/response, and its error travels in the response.
885
+ const result = await this.#batchRunner.run(
886
+ await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
887
+ );
888
+ error = result.errors.csr;
889
+ } catch (err) {
890
+ error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
891
+ }
445
892
  if (error) {
446
893
  this.#logger.error(`csr-build failed: ${error}`);
447
894
  await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
448
895
  return;
449
896
  }
450
- this.#csrActive = true;
897
+ this.#csrArmSucceeded = true;
898
+ this.#patcher?.forget();
451
899
  this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
452
900
  await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
453
901
  });
902
+ this.#csrGate = this.#armingCsr(armed);
903
+ await armed;
904
+ }
905
+
906
+ //? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
907
+ //? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
908
+ //? patch CSR first while the second still builds.
909
+ #beginCsrArm(): void {
910
+ if (this.#csrArms === 0) {
911
+ this.#csrWasActive = this.#csrActive;
912
+ this.#csrArmSucceeded = false;
913
+ }
914
+ this.#csrArms += 1;
915
+ this.#csrArming = true;
916
+ }
917
+
918
+ #armingCsr(armed: Promise<void>): Promise<void> {
919
+ const settle = () => {
920
+ this.#csrArms -= 1;
921
+ if (this.#csrArms > 0) return;
922
+ this.#csrArming = false;
923
+ this.#csrActive = this.#csrWasActive || this.#csrArmSucceeded;
924
+ };
925
+ return armed.then(settle, settle);
454
926
  }
455
927
 
456
928
  static #csrArmedByEnv() {
@@ -482,14 +954,26 @@ class IncrementalBuilder {
482
954
  // The env flag carries an armed CSR session across builder restarts, so the replacement rebuilds it after ready.
483
955
  async rearmCsrFromEnv(): Promise<void> {
484
956
  if (!IncrementalBuilder.#csrArmedByEnv()) return;
957
+ this.#beginCsrArm();
485
958
  this.#csrActive = true;
486
- await this.#enqueueWork("build-csr-rearm", async () => {
959
+ //? The env keeps CSR armed whatever this build does, as it did before the builder restarted.
960
+ this.#csrArmSucceeded = true;
961
+ const rearmed = this.#workQueue.enqueue("build-csr-rearm", async () => {
962
+ //? Relayed, unlike an on-demand build: a rebuild for a metadata save must reach the CSR tabs as their reload.
487
963
  const result = await this.#batchRunner.run(
488
964
  await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
965
+ (msg) => BuilderChannel.emit(msg),
489
966
  );
967
+ this.#patcher?.forget();
490
968
  if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
491
969
  else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
492
970
  });
971
+ this.#csrGate = this.#armingCsr(rearmed);
972
+ await rearmed.catch((err: unknown) =>
973
+ this.#logger.error(
974
+ `csr-rearm failed: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
975
+ ),
976
+ );
493
977
  }
494
978
 
495
979
  // A boot compile error must not kill the builder, the dev server's only file watcher: report it, emit builder-ready
@@ -499,10 +983,11 @@ class IncrementalBuilder {
499
983
  bootError: unknown,
500
984
  logger: Logger,
501
985
  runner: BuildBatchRunner,
986
+ initialGeneration: number,
502
987
  ): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
503
988
  const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
504
989
  logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
505
- let generation = 0;
990
+ let generation = initialGeneration;
506
991
  const sendFailure = (files: string[], message: string) => {
507
992
  BuilderChannel.emit({
508
993
  type: "build-status",
@@ -511,6 +996,8 @@ class IncrementalBuilder {
511
996
  };
512
997
  sendFailure([], firstMessage);
513
998
  BuilderChannel.emit({ type: "builder-ready" });
999
+ // Nothing builds until a save fixes the boot, so the next app's boot need not wait for this one.
1000
+ BuilderChannel.emit({ type: "boot-armed" });
514
1001
  return new Promise((resolve, reject) => {
515
1002
  void (async () => {
516
1003
  const roots = await new WatchRootResolver(app).resolve();
@@ -568,8 +1055,11 @@ class IncrementalBuilder {
568
1055
  BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
569
1056
  return;
570
1057
  }
571
- if (msg.type === "build-route") void builder.handleBuildRoute(msg);
572
- else void builder.handleBuildCsr(msg);
1058
+ const handled = msg.type === "build-route" ? builder.handleBuildRoute(msg) : builder.handleBuildCsr(msg);
1059
+ // Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
1060
+ void handled.catch((err: unknown) =>
1061
+ logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
1062
+ );
573
1063
  });
574
1064
  // Closes when the host dies (even by SIGKILL): nothing left to drain, and an orphan would rebuild for nobody.
575
1065
  process.on("disconnect", () => {
@@ -578,18 +1068,28 @@ class IncrementalBuilder {
578
1068
  });
579
1069
  let recoveredFiles: string[] | null = null;
580
1070
  const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
1071
+ const initialGeneration = IncrementalBuilder.#initialGeneration();
581
1072
  try {
582
- builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app, bootRunner)) });
1073
+ const deps = await IncrementalBuilder.#buildBootDeps(app, bootRunner);
1074
+ builder = new IncrementalBuilder({ app, watch, initialGeneration, ...deps });
583
1075
  } catch (err) {
584
1076
  if (!watch) throw err;
585
- const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
1077
+ const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner, initialGeneration);
586
1078
  builder = recovered.builder;
587
1079
  recoveredFiles = recovered.changedFiles;
588
1080
  }
589
1081
  await builder.boot();
590
1082
  if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
591
1083
  else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
592
- await builder.rearmCsrFromEnv();
1084
+ await Promise.all([builder.rearmCsrFromEnv(), builder.armSsrRegistry()]);
1085
+ BuilderChannel.emit({ type: "boot-armed" });
1086
+ }
1087
+
1088
+ //? A replacement builder continues past the host's last generation: from 0, the save fixing an error read older than
1089
+ //? the failure the tabs hold, and from that generation itself its arming builds' ok did not read as the fix either.
1090
+ static #initialGeneration(): number {
1091
+ const generation = Number(process.env.AKAN_BUILDER_INITIAL_GENERATION);
1092
+ return Number.isInteger(generation) && generation > 0 ? generation + 1 : 0;
593
1093
  }
594
1094
  }
595
1095