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

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 (57) hide show
  1. package/DEV_RUNTIME_KNOBS.md +37 -22
  2. package/akanApp/akanApp.host.ts +34 -1
  3. package/akanApp/devBootLatch.test.ts +39 -0
  4. package/akanApp/devBootLatch.ts +39 -0
  5. package/akanApp/devHostPolicy.test.ts +25 -0
  6. package/akanApp/devHostPolicy.ts +27 -2
  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 +37 -0
  17. package/frontendBuild/clientEntryDiscovery.ts +10 -5
  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.ts +51 -9
  23. package/frontendBuild/csrDevPatcher.test.ts +340 -0
  24. package/frontendBuild/csrDevPatcher.ts +374 -0
  25. package/frontendBuild/csrDevPaths.ts +60 -0
  26. package/frontendBuild/csrDevResolver.test.ts +165 -0
  27. package/frontendBuild/csrDevResolver.ts +70 -18
  28. package/frontendBuild/csrDevTypes.ts +14 -1
  29. package/frontendBuild/csrEntryFiles.ts +24 -0
  30. package/frontendBuild/cssCompiler.ts +2 -4
  31. package/frontendBuild/devRegistryBundler.ts +177 -0
  32. package/frontendBuild/hmrWatcher.test.ts +51 -0
  33. package/frontendBuild/hmrWatcher.ts +17 -3
  34. package/frontendBuild/index.ts +3 -0
  35. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  36. package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
  37. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  38. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  39. package/frontendBuild/routeClientBuilder.ts +79 -18
  40. package/frontendBuild/serverGraphFile.test.ts +77 -0
  41. package/frontendBuild/serverGraphFile.ts +93 -0
  42. package/frontendBuild/ssrDevBundler.ts +91 -0
  43. package/incrementalBuilder/buildBatch.proc.ts +94 -16
  44. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  45. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  46. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  47. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  48. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  49. package/incrementalBuilder/incrementalBuilder.host.test.ts +1 -1
  50. package/incrementalBuilder/incrementalBuilder.host.ts +32 -3
  51. package/incrementalBuilder/incrementalBuilder.proc.test.ts +129 -2
  52. package/incrementalBuilder/incrementalBuilder.proc.ts +518 -52
  53. package/integration/devStability.integration.test.ts +18 -7
  54. package/package.json +2 -2
  55. package/transforms/rscUseClientTransform.ts +7 -0
  56. package/transforms/transforms.test.ts +13 -0
  57. 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 });
106
169
  return {
107
170
  type: "build-route-res",
108
171
  id: msg.id,
@@ -116,12 +179,13 @@ 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", { generation: this.#generation, ok: false, files: msg.seeds, message: errMsg });
125
189
  return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
126
190
  }
127
191
  }
@@ -141,23 +205,35 @@ class IncrementalBuilder {
141
205
  },
142
206
  });
143
207
  }
144
- async #enqueueWork<T>(label: string, fn: () => Promise<T>): Promise<T> {
208
+ async #enqueueFast<T>(label: string, fn: () => Promise<T>): Promise<T> {
145
209
  const started = Date.now();
146
210
  this.#inFlight += 1;
147
- const run = this.#workQueue.then(fn, fn);
148
- this.#workQueue = run.then(() => undefined).catch(() => undefined);
211
+ const run = this.#fastQueue.then(fn, fn);
212
+ this.#fastQueue = run.then(() => undefined).catch(() => undefined);
149
213
  try {
150
214
  return await run;
151
215
  } finally {
152
216
  this.#inFlight -= 1;
153
217
  this.#workCount += 1;
154
- this.#logger.verbose(`[work-queue] ${label} finished in ${Date.now() - started}ms`);
218
+ this.#logger.verbose(`[fast-queue] ${label} finished in ${Date.now() - started}ms`);
155
219
  this.#reportMetrics();
156
220
  }
157
221
  }
158
222
 
159
223
  get #idle(): boolean {
160
- return this.#inFlight === 0 && this.#cssRebuildTimer === null;
224
+ return this.#inFlight === 0 && this.#workQueue.size === 0 && this.#cssRebuildTimer === null;
225
+ }
226
+
227
+ // Callers catch their own errors: the promise handed in never rejects.
228
+ #runBeside(work: Promise<void>): Promise<void> {
229
+ this.#inFlight += 1;
230
+ const tracked: Promise<void> = work.finally(() => {
231
+ this.#inFlight -= 1;
232
+ this.#beside.delete(tracked);
233
+ this.#reportMetrics();
234
+ });
235
+ this.#beside.add(tracked);
236
+ return tracked;
161
237
  }
162
238
 
163
239
  // Idle only: the host recycles on these metrics, and a recycle decided mid-work would truncate it or race the drain.
@@ -184,8 +260,15 @@ class IncrementalBuilder {
184
260
  this.#cssRebuildTimer = null;
185
261
  this.#pendingCssRebuild = null;
186
262
  }
187
- await this.#workQueue.catch(() => undefined);
263
+ // The fast lane first: a save it is still handling may queue a batch in the slow one.
264
+ await this.#fastQueue.catch(() => undefined);
265
+ await this.#workQueue.drain();
188
266
  await this.#cssRebuildQueue.catch(() => undefined);
267
+ while (this.#beside.size > 0) await Promise.all([...this.#beside]);
268
+ if (this.#delegations.size > 0)
269
+ this.#logger.verbose(
270
+ `registry saves handed to build workers this session: ${[...this.#delegations].map(([reason, count]) => `${reason}=${count}`).join(", ")}`,
271
+ );
189
272
  // Drained queues are not delivered results: `process.exit` drops unflushed ipc writes (a relayed `css-updated`).
190
273
  const flushed = await BuilderChannel.drain();
191
274
  this.#logger.debug(
@@ -264,7 +347,7 @@ class IncrementalBuilder {
264
347
  roots,
265
348
  logger: this.#logger,
266
349
  onBatch: async (batch: ChangeBatch) => {
267
- await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(batch));
350
+ await this.#enqueueFast("hmr-batch", async () => this.#handleWatchBatch(batch));
268
351
  },
269
352
  });
270
353
  await watcher.start();
@@ -276,6 +359,7 @@ class IncrementalBuilder {
276
359
  const rawKinds = new Set(batch.kinds);
277
360
  if (rawKinds.size === 0) return;
278
361
  const generation = ++this.#generation;
362
+ const trace = { ...batch.trace, batchAt: Date.now() };
279
363
  //* Auto-import edits touch only this batch's files, so they rebuild in this same generation.
280
364
  const [autoImport, indexSync] = await CodegenLock.run(
281
365
  this.#app.workspace.workspaceRoot,
@@ -301,21 +385,24 @@ class IncrementalBuilder {
301
385
  );
302
386
  for (const error of indexSync.errors) this.#logger.error(error);
303
387
 
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
- }
388
+ // Invalidated in the slow lane, under the route builds that read it; a registry check reading it beside them caches
389
+ // nothing a walk began before the invalidation (the discovery's epoch).
390
+ const discovery = kinds.includes("code") ? { files, refresh: kinds.includes("config"), generation } : undefined;
312
391
 
313
392
  if (hasSyncErrors) {
393
+ this.#barrelFailed = true;
314
394
  this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
315
395
  BuilderChannel.emit(event);
396
+ if (discovery)
397
+ void this.#workQueue
398
+ .enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
399
+ .catch(this.#slowLaneFailed("discovery"));
316
400
  return;
317
401
  }
318
- if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
402
+ // After a failure too, where nothing changed on disk: the host re-sends a phase still failing to every new tab.
403
+ if (indexSync.changedFiles.length > 0 || this.#barrelFailed)
404
+ this.#sendBuildStatus("barrel", { generation, ok: true, files });
405
+ this.#barrelFailed = false;
319
406
 
320
407
  // Server-only generations skip the client: a fresh pages buildId would rsc-refresh browsers for no visible change.
321
408
  const rebuildClient = devPlan.actions.includes("rebuild-client");
@@ -332,47 +419,364 @@ class IncrementalBuilder {
332
419
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
333
420
  }
334
421
 
335
- const needs: BuildBatchNeed[] = [];
422
+ BuilderChannel.emit(event);
423
+
336
424
  if (kinds.includes("code") && rebuildClient) {
337
- if (this.#csrActive) needs.push("csr");
338
- else
425
+ // Css rides the pages batch rather than its own debounce: the slow lane folds a burst of saves into one batch.
426
+ const needs: BuildBatchNeed[] = ["pages", "css"];
427
+ if (!this.#csrActive)
339
428
  this.#logger.verbose(
340
429
  `csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
341
430
  );
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");
431
+ //? One after the other, since two compiles side by side slow both. CSR goes first unless its build is being armed:
432
+ //? a CSR patch then waits behind #csrGate, which SSR tabs must not.
433
+ const patchCsr = async () => (this.#csrActive ? await this.#patchCsr(generation, files, trace) : false);
434
+ const patchSsr = async () => await this.#patchSsr(generation, files, trace);
435
+ let csrToWorker: boolean;
436
+ let ssrToWorker: boolean;
437
+ if (this.#csrArming) {
438
+ ssrToWorker = await patchSsr();
439
+ csrToWorker = await patchCsr();
440
+ } else {
441
+ csrToWorker = await patchCsr();
442
+ ssrToWorker = await patchSsr();
443
+ }
444
+ if (csrToWorker) needs.unshift("csr");
445
+ if (ssrToWorker) needs.unshift("ssr");
446
+ const batch: BatchJob = { generation, needs, changedFiles: files, trace, ...(discovery ? { discovery } : {}) };
447
+ // A worker's registry build rewrites the directory its patcher reads, so the next save waits for it.
448
+ if (needs.includes("csr") || needs.includes("ssr")) {
449
+ await this.#workQueue.enqueueBatch(batch);
450
+ if (needs.includes("csr")) this.#patcher?.forget();
451
+ if (needs.includes("ssr")) this.#ssrPatcher?.forget();
452
+ } else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
453
+ return;
346
454
  }
347
-
348
- BuilderChannel.emit(event);
349
-
350
- if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
455
+ if (discovery)
456
+ void this.#workQueue
457
+ .enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
458
+ .catch(this.#slowLaneFailed("discovery"));
351
459
  // Css-only batches keep the debounce: they arrive in bursts while a stylesheet is edited.
352
- else if (kinds.includes("css")) {
460
+ if (kinds.includes("css")) {
353
461
  this.scheduleCssRebuild({ generation, changedFiles: files });
354
462
  this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
355
463
  }
356
464
  }
357
465
 
466
+ // True when the save has to go to a build worker: the patcher handed it back, or threw (the worker reports why).
467
+ async #patchCsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
468
+ const patcher = this.#patcher;
469
+ if (!patcher) return true;
470
+ await this.#csrGate;
471
+ // The arming build this save waited for failed: CSR is off again, and the next request arms it anew.
472
+ if (!this.#csrActive) return false;
473
+ const started = Date.now();
474
+ const marker = path.join(this.#csrBundler.outDir, CSR_DEV_PATCHING_MARKER);
475
+ await Bun.write(marker, String(process.pid));
476
+ try {
477
+ const result = await patcher.update(files, {
478
+ announce: (update) => {
479
+ const now = Date.now();
480
+ BuilderChannel.emit({
481
+ type: "csr-updated",
482
+ data: {
483
+ generation: update.generation,
484
+ mode: "registry",
485
+ reload: update.reload,
486
+ reason: update.reason,
487
+ patchUrl: update.patchUrl,
488
+ changedIds: update.changedIds,
489
+ trace: { ...trace, patchAt: now, sentAt: now },
490
+ },
491
+ });
492
+ },
493
+ });
494
+ if (result.kind === "delegate") {
495
+ this.#logger.verbose(
496
+ `csr-patch handed to a build worker (${this.#countDelegation("csr", result.reason)}): ${result.reason}`,
497
+ );
498
+ return true;
499
+ }
500
+ this.#sendBuildStatus("csr", { generation, ok: true, files });
501
+ this.#logger.verbose(
502
+ result.kind === "unchanged"
503
+ ? `csr-patch unchanged (${Date.now() - started}ms)`
504
+ : `csr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
505
+ );
506
+ return false;
507
+ } catch (err) {
508
+ const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
509
+ if (IncrementalBuilder.#isCompileError(err)) {
510
+ this.#sendBuildStatus("csr", { generation, ok: false, files, message });
511
+ return false;
512
+ }
513
+ this.#logger.verbose(`csr-patch threw; a build worker takes this save: ${message}`);
514
+ patcher.forget();
515
+ return true;
516
+ } finally {
517
+ await rm(marker, { force: true });
518
+ }
519
+ }
520
+
521
+ // True when the save has to go to a build worker, as for CSR.
522
+ async #patchSsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
523
+ const patcher = this.#ssrPatcher;
524
+ if (!patcher) return true;
525
+ const hold = await ServerGraphFile.touches(await ServerGraphFile.read(this.#artifactDir), files, (file) =>
526
+ ServerGraphFile.clientExportsOf(file),
527
+ );
528
+ return await this.#withSsrLock(
529
+ async () => await this.#runSsrPatcher(patcher, files, { trace, hold, batchGeneration: generation }),
530
+ );
531
+ }
532
+
533
+ //* Adds the entries a route build names to the registry, since the tab requires them by id. A registry that cannot
534
+ //* take them fails no route build: the page answers, and its tab waits for the registry while the overlay says why.
535
+ async #ensureSsrEntries(entries: string[]): Promise<void> {
536
+ if (entries.length === 0) return;
537
+ //? The boot build takes every entry the routes reach, so the first page answers now and its tab waits for the
538
+ //? registry (boot.json) instead; whatever that build missed is added right after it lands.
539
+ const arming = this.#ssrArming;
540
+ if (arming) {
541
+ void this.#runBeside(
542
+ arming.then(async () => await this.#ensureSsrEntries(entries)).catch(this.#slowLaneFailed("ssr-ensure")),
543
+ );
544
+ return;
545
+ }
546
+ await this.#withSsrLock(async () => {
547
+ const patcher = this.#ssrPatcher;
548
+ // The builder's generation, not the request's: its build-status joins the saves' `ssr` ones.
549
+ const generation = this.#generation;
550
+ if (
551
+ patcher &&
552
+ !(await this.#runSsrPatcher(patcher, [], { roots: entries, onlyRoots: true, batchGeneration: generation }))
553
+ )
554
+ return;
555
+ //? Read, not compiled: with the patcher off, the save's own worker batch already built every entry.
556
+ if (!patcher && (await this.#ssrBundler.holds(entries))) return;
557
+ //? Not retried per route build: the save that fixes the error rebuilds it, where each route build retrying held
558
+ //? the SSR lock that save's patch waits on.
559
+ if (this.#ssrBroken) return;
560
+ if (this.#ssrCrashedAt !== null && Date.now() - this.#ssrCrashedAt < IncrementalBuilder.#crashRetryMs) {
561
+ this.#retryAfterCrash(this.#ssrCrashedAt, entries);
562
+ return;
563
+ }
564
+ await this.#runSsrWorker(generation).catch((err: unknown) => {
565
+ this.#logger.error(
566
+ `ssr-registry could not take a route's entries; the save that fixes it rebuilds it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
567
+ );
568
+ });
569
+ });
570
+ }
571
+
572
+ //? Once the crash window closes, for the entries skipped in it: later route builds may name none (the first one
573
+ //? announced every entry to the route cache), so without this a tab could wait on boot.json until the next save.
574
+ #retryAfterCrash(crashedAt: number, entries: string[]): void {
575
+ for (const entry of entries) this.#crashRetryEntries.add(entry);
576
+ if (this.#crashRetryTimer) return;
577
+ const timer = setTimeout(
578
+ () => {
579
+ this.#crashRetryTimer = null;
580
+ const retried = [...this.#crashRetryEntries];
581
+ this.#crashRetryEntries.clear();
582
+ if (this.shuttingDown || retried.length === 0) return;
583
+ void this.#runBeside(this.#ensureSsrEntries(retried)).catch(this.#slowLaneFailed("ssr-crash-retry"));
584
+ },
585
+ Math.max(0, crashedAt + IncrementalBuilder.#crashRetryMs - Date.now()),
586
+ );
587
+ timer.unref?.();
588
+ this.#crashRetryTimer = timer;
589
+ }
590
+
591
+ //* The boot build of the SSR registry runs beside the slow lane, holding only the SSR lock: the first page's route
592
+ //* build bundles meanwhile instead of queueing behind it (on apps/akan it cost the first page about a second).
593
+ async armSsrRegistry(): Promise<void> {
594
+ //? `AKAN_DEV_SSR_ARM_DELAY_MS` is a test hook: an E2E has a tab reconnect before the boot build has run.
595
+ const delayMs = Number(process.env.AKAN_DEV_SSR_ARM_DELAY_MS);
596
+ if (Number.isInteger(delayMs) && delayMs > 0) await Bun.sleep(delayMs);
597
+ const arm = async () =>
598
+ await this.#withSsrLock(async () => {
599
+ const generation = this.#generation;
600
+ if (this.#ssrPatcher && !(await this.#runSsrPatcher(this.#ssrPatcher, [], { batchGeneration: generation })))
601
+ return;
602
+ await this.#runSsrWorker(generation);
603
+ }).catch((err: unknown) => {
604
+ this.#logger.error(
605
+ `ssr-registry boot build failed; the next save retries it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
606
+ );
607
+ });
608
+ const arming = this.#runBeside(
609
+ (async () => {
610
+ await arm();
611
+ //? A boot build whose worker was killed (a small container's OOM) is tried once more before `boot-armed` lets
612
+ //? the next app boot: retried after that, the two biggest workers of a boot would run side by side.
613
+ const crashedAt = this.#ssrCrashedAt;
614
+ if (crashedAt === null) return;
615
+ while (!this.shuttingDown && Date.now() < crashedAt + IncrementalBuilder.#crashRetryMs) await Bun.sleep(250);
616
+ if (!this.shuttingDown) await arm();
617
+ })(),
618
+ );
619
+ this.#ssrArming = arming;
620
+ await arming;
621
+ this.#ssrArming = null;
622
+ }
623
+
624
+ //? A save the user broke fails the same way in a worker, so it is reported here and the patcher keeps its state: the
625
+ //? modules it could not compile stay pending in the graph and the fixing save compiles them.
626
+ static #isCompileError(error: unknown): boolean {
627
+ return error instanceof AggregateError;
628
+ }
629
+
630
+ // Voided so the fast lane need not wait; unhandled, a rejection would exit the builder and drop the watcher with it.
631
+ #slowLaneFailed(label: string) {
632
+ return (error: unknown) =>
633
+ this.#logger.error(
634
+ `${label} failed in the slow lane: ${ApplicationBuildReporter.formatError(error, this.#app.workspace.workspaceRoot)}`,
635
+ );
636
+ }
637
+
638
+ // A reason's own detail (which import) follows its first colon, so saves of one kind count together.
639
+ #countDelegation(registry: "csr" | "ssr", reason: string): string {
640
+ const key = `${registry}:${reason.split(":")[0]}`;
641
+ const count = (this.#delegations.get(key) ?? 0) + 1;
642
+ this.#delegations.set(key, count);
643
+ return `#${count} for this reason`;
644
+ }
645
+
646
+ async #withSsrLock<T>(fn: () => Promise<T>): Promise<T> {
647
+ const run = this.#ssrLock.then(fn, fn);
648
+ this.#ssrLock = run.then(
649
+ () => undefined,
650
+ () => undefined,
651
+ );
652
+ return await run;
653
+ }
654
+
655
+ // Runs the worker directly, under the SSR lock its callers hold: queueing it could wait on a batch that waits on them.
656
+ async #runSsrWorker(generation: number): Promise<void> {
657
+ const result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
658
+ this.#ssrPatcher?.forget();
659
+ this.#noteSsrWorker(result);
660
+ if (result.errors.ssr) throw new Error(result.errors.ssr);
661
+ }
662
+
663
+ #noteSsrWorker(result: BuildBatchResult): void {
664
+ const crashed = !!result.crashed && (result.crashedNeeds ?? ["ssr"]).includes("ssr");
665
+ this.#ssrBroken = !!result.errors.ssr && !crashed;
666
+ this.#ssrCrashedAt = crashed ? Date.now() : null;
667
+ }
668
+
669
+ async #runSsrPatcher(
670
+ patcher: CsrDevPatcher,
671
+ files: string[],
672
+ {
673
+ roots,
674
+ onlyRoots,
675
+ trace,
676
+ hold,
677
+ batchGeneration,
678
+ }: { roots?: string[]; onlyRoots?: boolean; trace?: HmrTrace; hold?: boolean; batchGeneration?: number } = {},
679
+ ): Promise<boolean> {
680
+ const started = Date.now();
681
+ const marker = path.join(this.#ssrBundler.outDir, CSR_DEV_PATCHING_MARKER);
682
+ await Bun.write(marker, String(process.pid));
683
+ try {
684
+ const result = await patcher.update(files, {
685
+ roots,
686
+ onlyRoots,
687
+ announce: (update) => {
688
+ const now = Date.now();
689
+ BuilderChannel.emit({
690
+ type: "ssr-updated",
691
+ data: {
692
+ generation: update.generation,
693
+ reload: update.reload,
694
+ reason: update.reason,
695
+ patchUrl: update.patchUrl,
696
+ changedIds: update.changedIds,
697
+ ...(trace ? { trace: { ...trace, patchAt: now, sentAt: now } } : {}),
698
+ ...(hold ? { hold } : {}),
699
+ ...(batchGeneration !== undefined ? { batchGeneration } : {}),
700
+ ...(update.epoch !== undefined ? { epoch: update.epoch } : {}),
701
+ ...(update.first ? { first: true } : {}),
702
+ },
703
+ });
704
+ },
705
+ });
706
+ if (result.kind === "delegate") {
707
+ this.#logger.verbose(
708
+ `ssr-patch handed to a build worker (${this.#countDelegation("ssr", result.reason)}): ${result.reason}`,
709
+ );
710
+ return true;
711
+ }
712
+ //? A route build's check reports only a failure: it compiles the roots the registry lacks and nothing else, so
713
+ //? its ok, of the builder's current generation, would read as the fix of a save that broke something else.
714
+ if (!onlyRoots) {
715
+ this.#ssrBroken = false;
716
+ this.#ssrCrashedAt = null;
717
+ this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: true, files });
718
+ }
719
+ this.#logger.verbose(
720
+ result.kind === "unchanged"
721
+ ? `ssr-patch unchanged (${Date.now() - started}ms)`
722
+ : `ssr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
723
+ );
724
+ return false;
725
+ } catch (err) {
726
+ const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
727
+ if (IncrementalBuilder.#isCompileError(err)) {
728
+ this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: false, files, message });
729
+ return false;
730
+ }
731
+ this.#logger.verbose(`ssr-patch threw; a build worker takes it: ${message}`);
732
+ patcher.forget();
733
+ return true;
734
+ } finally {
735
+ await rm(marker, { force: true });
736
+ }
737
+ }
738
+
739
+ async #runQueuedBatch({ discovery, ...work }: BatchJob): Promise<void> {
740
+ if (discovery) await this.#refreshDiscovery(discovery);
741
+ //? The slow lane runs in order, so every batch up to this one has had its discovery job.
742
+ this.#discoveredThrough = Math.max(this.#discoveredThrough, work.generation);
743
+ // A worker writing ssr-dev must not overlap the boot build, which no longer runs in this lane.
744
+ if (work.needs.includes("ssr"))
745
+ await this.#withSsrLock(async () => this.#noteSsrWorker(await this.#runBatch(work)));
746
+ else await this.#runBatch(work);
747
+ }
748
+
749
+ async #refreshDiscovery({ files, refresh, generation }: DiscoveryJob): Promise<void> {
750
+ const started = Date.now();
751
+ if (refresh) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
752
+ else this.#discovery.invalidate?.(files);
753
+ this.#discoveredThrough = Math.max(this.#discoveredThrough, generation);
754
+ this.#logger.verbose(`client-entry-discovery ${refresh ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`);
755
+ }
756
+
358
757
  async #runBatch(work: BatchWork): Promise<BuildBatchResult> {
359
758
  const { generation, needs, changedFiles } = work;
360
759
  const started = Date.now();
361
760
  const result = await this.#batchRunner.run(await this.#batchRequest(work), (msg) => BuilderChannel.emit(msg));
362
761
  if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
363
762
  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.
763
+ // A need the worker died before reporting goes red here instead of looking silently successful.
365
764
  if (result.crashed) {
366
765
  // `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
367
- for (const need of needs)
766
+ for (const need of result.crashedNeeds ?? needs)
368
767
  if (need !== "base")
369
- this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
768
+ this.#sendBuildStatus(need, {
769
+ generation,
770
+ ok: false,
771
+ files: changedFiles,
772
+ message: `${result.errors[need] ?? "the build worker crashed"}; the next save builds it again`,
773
+ });
370
774
  }
371
775
  if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
372
776
  return result;
373
777
  }
374
778
 
375
- async #batchRequest({ generation, needs, changedFiles }: BatchWork): Promise<BuildBatchRequest> {
779
+ async #batchRequest({ generation, needs, changedFiles, trace }: BatchWork): Promise<BuildBatchRequest> {
376
780
  return {
377
781
  appName: this.#app.name,
378
782
  workspaceRoot: this.#app.workspace.workspaceRoot,
@@ -384,6 +788,7 @@ class IncrementalBuilder {
384
788
  optimizedFonts: this.#optimizedFonts,
385
789
  cssAssets: this.#artifact.cssAssets ?? null,
386
790
  artifactDir: path.resolve(this.#artifactDir),
791
+ ...(trace ? { trace } : {}),
387
792
  };
388
793
  }
389
794
 
@@ -396,7 +801,7 @@ class IncrementalBuilder {
396
801
  // The backend still serves the last-good bundle after a degraded boot; push fresh pages/css without another edit.
397
802
  async announceRecoveredState(changedFiles: string[]): Promise<void> {
398
803
  const generation = ++this.#generation;
399
- await this.#enqueueWork("boot-recovered", async () => {
804
+ await this.#workQueue.enqueue("boot-recovered", async () => {
400
805
  await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
401
806
  });
402
807
  }
@@ -435,22 +840,55 @@ class IncrementalBuilder {
435
840
 
436
841
  // On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
437
842
  async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
438
- await this.#enqueueWork("build-csr", async (): Promise<void> => {
843
+ //? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
844
+ this.#beginCsrArm();
845
+ this.#csrActive = true;
846
+ const armed = this.#workQueue.enqueue("build-csr", async (): Promise<void> => {
439
847
  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;
848
+ let error: string | undefined;
849
+ try {
850
+ // Not relayed: an on-demand CSR build is request/response, and its error travels in the response.
851
+ const result = await this.#batchRunner.run(
852
+ await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
853
+ );
854
+ error = result.errors.csr;
855
+ } catch (err) {
856
+ error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
857
+ }
445
858
  if (error) {
446
859
  this.#logger.error(`csr-build failed: ${error}`);
447
860
  await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
448
861
  return;
449
862
  }
450
- this.#csrActive = true;
863
+ this.#csrArmSucceeded = true;
864
+ this.#patcher?.forget();
451
865
  this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
452
866
  await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
453
867
  });
868
+ this.#csrGate = this.#armingCsr(armed);
869
+ await armed;
870
+ }
871
+
872
+ //? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
873
+ //? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
874
+ //? patch CSR first while the second still builds.
875
+ #beginCsrArm(): void {
876
+ if (this.#csrArms === 0) {
877
+ this.#csrWasActive = this.#csrActive;
878
+ this.#csrArmSucceeded = false;
879
+ }
880
+ this.#csrArms += 1;
881
+ this.#csrArming = true;
882
+ }
883
+
884
+ #armingCsr(armed: Promise<void>): Promise<void> {
885
+ const settle = () => {
886
+ this.#csrArms -= 1;
887
+ if (this.#csrArms > 0) return;
888
+ this.#csrArming = false;
889
+ this.#csrActive = this.#csrWasActive || this.#csrArmSucceeded;
890
+ };
891
+ return armed.then(settle, settle);
454
892
  }
455
893
 
456
894
  static #csrArmedByEnv() {
@@ -482,14 +920,26 @@ class IncrementalBuilder {
482
920
  // The env flag carries an armed CSR session across builder restarts, so the replacement rebuilds it after ready.
483
921
  async rearmCsrFromEnv(): Promise<void> {
484
922
  if (!IncrementalBuilder.#csrArmedByEnv()) return;
923
+ this.#beginCsrArm();
485
924
  this.#csrActive = true;
486
- await this.#enqueueWork("build-csr-rearm", async () => {
925
+ //? The env keeps CSR armed whatever this build does, as it did before the builder restarted.
926
+ this.#csrArmSucceeded = true;
927
+ const rearmed = this.#workQueue.enqueue("build-csr-rearm", async () => {
928
+ //? Relayed, unlike an on-demand build: a rebuild for a metadata save must reach the CSR tabs as their reload.
487
929
  const result = await this.#batchRunner.run(
488
930
  await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
931
+ (msg) => BuilderChannel.emit(msg),
489
932
  );
933
+ this.#patcher?.forget();
490
934
  if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
491
935
  else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
492
936
  });
937
+ this.#csrGate = this.#armingCsr(rearmed);
938
+ await rearmed.catch((err: unknown) =>
939
+ this.#logger.error(
940
+ `csr-rearm failed: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
941
+ ),
942
+ );
493
943
  }
494
944
 
495
945
  // A boot compile error must not kill the builder, the dev server's only file watcher: report it, emit builder-ready
@@ -499,10 +949,11 @@ class IncrementalBuilder {
499
949
  bootError: unknown,
500
950
  logger: Logger,
501
951
  runner: BuildBatchRunner,
952
+ initialGeneration: number,
502
953
  ): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
503
954
  const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
504
955
  logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
505
- let generation = 0;
956
+ let generation = initialGeneration;
506
957
  const sendFailure = (files: string[], message: string) => {
507
958
  BuilderChannel.emit({
508
959
  type: "build-status",
@@ -511,6 +962,8 @@ class IncrementalBuilder {
511
962
  };
512
963
  sendFailure([], firstMessage);
513
964
  BuilderChannel.emit({ type: "builder-ready" });
965
+ // Nothing builds until a save fixes the boot, so the next app's boot need not wait for this one.
966
+ BuilderChannel.emit({ type: "boot-armed" });
514
967
  return new Promise((resolve, reject) => {
515
968
  void (async () => {
516
969
  const roots = await new WatchRootResolver(app).resolve();
@@ -568,8 +1021,11 @@ class IncrementalBuilder {
568
1021
  BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
569
1022
  return;
570
1023
  }
571
- if (msg.type === "build-route") void builder.handleBuildRoute(msg);
572
- else void builder.handleBuildCsr(msg);
1024
+ const handled = msg.type === "build-route" ? builder.handleBuildRoute(msg) : builder.handleBuildCsr(msg);
1025
+ // Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
1026
+ void handled.catch((err: unknown) =>
1027
+ logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
1028
+ );
573
1029
  });
574
1030
  // Closes when the host dies (even by SIGKILL): nothing left to drain, and an orphan would rebuild for nobody.
575
1031
  process.on("disconnect", () => {
@@ -578,18 +1034,28 @@ class IncrementalBuilder {
578
1034
  });
579
1035
  let recoveredFiles: string[] | null = null;
580
1036
  const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
1037
+ const initialGeneration = IncrementalBuilder.#initialGeneration();
581
1038
  try {
582
- builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app, bootRunner)) });
1039
+ const deps = await IncrementalBuilder.#buildBootDeps(app, bootRunner);
1040
+ builder = new IncrementalBuilder({ app, watch, initialGeneration, ...deps });
583
1041
  } catch (err) {
584
1042
  if (!watch) throw err;
585
- const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
1043
+ const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner, initialGeneration);
586
1044
  builder = recovered.builder;
587
1045
  recoveredFiles = recovered.changedFiles;
588
1046
  }
589
1047
  await builder.boot();
590
1048
  if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
591
1049
  else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
592
- await builder.rearmCsrFromEnv();
1050
+ await Promise.all([builder.rearmCsrFromEnv(), builder.armSsrRegistry()]);
1051
+ BuilderChannel.emit({ type: "boot-armed" });
1052
+ }
1053
+
1054
+ //? A replacement builder continues past the host's last generation: from 0, the save fixing an error read older than
1055
+ //? the failure the tabs hold, and from that generation itself its arming builds' ok did not read as the fix either.
1056
+ static #initialGeneration(): number {
1057
+ const generation = Number(process.env.AKAN_BUILDER_INITIAL_GENERATION);
1058
+ return Number.isInteger(generation) && generation > 0 ? generation + 1 : 0;
593
1059
  }
594
1060
  }
595
1061