@akanjs/devkit 2.4.1-rc.3 → 2.4.1-rc.5

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.
@@ -68,7 +68,7 @@ describe("IncrementalBuilderHost", () => {
68
68
 
69
69
  host.start({ onRestartReady });
70
70
  spawns[0]?.options.ipc?.({ type: "builder-ready" });
71
- expect(spawns[0]?.options.env?.AKAN_BUILDER_RECYCLED).toBeUndefined();
71
+ expect(spawns[0]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
72
72
 
73
73
  const reason = "rss=1300MiB>=1200MiB after 3 build(s)";
74
74
  expect(host.recycle(reason)).toBe(true);
@@ -80,7 +80,7 @@ describe("IncrementalBuilderHost", () => {
80
80
  // A planned exit skips the crash backoff — the dev server has no file watcher until it is back.
81
81
  spawns[0]?.options.onExit?.();
82
82
  expect(spawns).toHaveLength(2);
83
- expect(spawns[1]?.options.env?.AKAN_BUILDER_RECYCLED).toBe("1");
83
+ expect(spawns[1]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBe("1");
84
84
  spawns[1]?.options.ipc?.({ type: "builder-ready" });
85
85
  expect(onRestartReady).toHaveBeenCalledTimes(1);
86
86
  expect(host.status).toBe("ready");
@@ -90,11 +90,96 @@ describe("IncrementalBuilderHost", () => {
90
90
  expect(spawns).toHaveLength(2);
91
91
  await wait(1_050);
92
92
  expect(spawns).toHaveLength(3);
93
- expect(spawns[2]?.options.env?.AKAN_BUILDER_RECYCLED).toBeUndefined();
93
+ expect(spawns[2]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBeUndefined();
94
94
 
95
95
  host.stop();
96
96
  });
97
97
 
98
+ test("fails the requests a departing builder never answered", async () => {
99
+ const spawns = mockSpawns();
100
+ const messages: unknown[] = [];
101
+ const host = new IncrementalBuilderHost({
102
+ app: { cwdPath: "/tmp/app" } as never,
103
+ entry: "/tmp/builder.ts",
104
+ env: {},
105
+ onMessage: (message) => messages.push(message),
106
+ });
107
+
108
+ host.start();
109
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
110
+ expect(host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] })).toBe(true);
111
+ expect(host.send({ type: "build-csr", id: 2, reason: "device webview" })).toBe(true);
112
+ // Answered before the exit, so this one must not be failed again afterwards.
113
+ expect(host.send({ type: "build-route", id: 3, routeId: "c", seeds: [], knownEntries: [] })).toBe(true);
114
+ spawns[0]?.options.ipc?.({ type: "build-route-res", id: 3, ok: true, data: { routeId: "c" } });
115
+ messages.length = 0;
116
+
117
+ host.recycle("rss=1300MiB>=1200MiB after 3 build(s)");
118
+ spawns[0]?.options.onExit?.();
119
+
120
+ // Nothing else answers these: the builder only refuses requests that arrive after it starts shutting
121
+ // down, and a kill or a truncated write sends nothing at all.
122
+ expect(messages).toEqual([
123
+ {
124
+ type: "build-route-res",
125
+ id: 1,
126
+ ok: false,
127
+ error: "builder exited to release bundler memory before answering; reload to retry",
128
+ },
129
+ {
130
+ type: "build-csr-res",
131
+ id: 2,
132
+ ok: false,
133
+ error: "builder exited to release bundler memory before answering; reload to retry",
134
+ },
135
+ ]);
136
+
137
+ // The replacement owes nothing, so its own exit stays quiet.
138
+ spawns[1]?.options.ipc?.({ type: "builder-ready" });
139
+ messages.length = 0;
140
+ spawns[1]?.options.onExit?.();
141
+ expect(messages).toEqual([]);
142
+ await wait(1_050);
143
+
144
+ host.stop();
145
+ });
146
+
147
+ test("names a crash rather than a recycle, and answers on stop too", async () => {
148
+ const spawns = mockSpawns();
149
+ const messages: unknown[] = [];
150
+ const host = new IncrementalBuilderHost({
151
+ app: { cwdPath: "/tmp/app" } as never,
152
+ entry: "/tmp/builder.ts",
153
+ env: {},
154
+ onMessage: (message) => messages.push(message),
155
+ });
156
+
157
+ host.start();
158
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
159
+ host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] });
160
+ messages.length = 0;
161
+ spawns[0]?.options.onExit?.();
162
+ expect(messages).toEqual([
163
+ {
164
+ type: "build-route-res",
165
+ id: 1,
166
+ ok: false,
167
+ error: "builder exited unexpectedly before answering; reload once it is back",
168
+ },
169
+ ]);
170
+
171
+ // `stop()` clears the process before its exit callback runs, so the callback bails on its identity
172
+ // check and cannot be the only place this happens.
173
+ await wait(1_050);
174
+ spawns[1]?.options.ipc?.({ type: "builder-ready" });
175
+ messages.length = 0;
176
+ expect(host.send({ type: "build-route", id: 2, routeId: "b", seeds: [], knownEntries: [] })).toBe(true);
177
+ host.stop();
178
+ expect(messages).toEqual([
179
+ { type: "build-route-res", id: 2, ok: false, error: "builder was stopped before answering" },
180
+ ]);
181
+ });
182
+
98
183
  test("only recycles a builder that is ready", () => {
99
184
  const spawns = mockSpawns();
100
185
  const host = new IncrementalBuilderHost({
@@ -27,6 +27,11 @@ interface IncrementalBuilderStartOptions {
27
27
  onExit?: () => void;
28
28
  onReady?: () => void;
29
29
  onRestartReady?: () => void;
30
+ /**
31
+ * Ask the builder to re-announce the artifact it boots with. Needed whenever a *previous* builder's
32
+ * artifact may still be live in a running backend — after an rss recycle, and after an idle wake.
33
+ */
34
+ announceBootState?: boolean;
30
35
  }
31
36
 
32
37
  export class IncrementalBuilderHost {
@@ -56,6 +61,17 @@ export class IncrementalBuilderHost {
56
61
  #recycleRequested: boolean = false;
57
62
  #spawnAfterRecycle: boolean = false;
58
63
  #manualStop = false;
64
+ /**
65
+ * Requests handed to the running builder that it has not answered yet, keyed by the backend's
66
+ * correlation id.
67
+ *
68
+ * Nothing else answers a request whose builder exits while holding it: the builder only refuses
69
+ * requests that arrive *after* it starts shutting down, a kill or crash sends nothing at all, and even
70
+ * a clean drain races its own `process.exit`. The builder exits routinely — it is recycled whenever its
71
+ * RSS passes the ceiling — so a page request that happened to be mid route-build left the backend's
72
+ * promise pending and the browser tab spinning with no error and nothing to retry.
73
+ */
74
+ readonly #inFlight = new Map<number, "build-route" | "build-csr">();
59
75
  #startOptions: IncrementalBuilderStartOptions = {};
60
76
  constructor({ app, entry, env, onMessage }: IncrementalBuilderHostOptions) {
61
77
  this.app = app;
@@ -66,28 +82,38 @@ export class IncrementalBuilderHost {
66
82
  get status() {
67
83
  return this.#status;
68
84
  }
85
+ /**
86
+ * The running builder's pid, so the host can read its RSS from the OS between builds. The builder
87
+ * only reports its own metrics at work-completion points, which is the *peak*; on a platform that
88
+ * returns bundler arenas to the OS while idle, that sample goes stale within seconds.
89
+ */
90
+ get pid(): number | null {
91
+ return this.#proc?.pid ?? null;
92
+ }
69
93
  start(options: IncrementalBuilderStartOptions = {}) {
70
94
  if (this.#proc) this.stop();
71
95
  this.#manualStop = false;
72
96
  this.#startOptions = options;
97
+ this.#spawnAfterRecycle = options.announceBootState ?? false;
73
98
  this.#spawn(false);
74
99
  return this;
75
100
  }
76
101
  #spawn(isRestart: boolean) {
77
102
  this.#status = isRestart ? "restarting" : "starting";
78
103
  this.ready = false;
79
- // A recycled builder rebuilds every artifact, and the running backend still holds the previous
80
- // one; the flag is what tells the replacement to re-announce what it booted with.
104
+ // A fresh builder rebuilds every artifact while the running backend still holds the previous one;
105
+ // the flag is what tells it to re-announce what it booted with.
81
106
  const afterRecycle = this.#spawnAfterRecycle;
82
107
  this.#spawnAfterRecycle = false;
83
108
  let proc!: Bun.Subprocess<"ignore", "inherit", "inherit">;
84
109
  proc = Bun.spawn(["bun", this.entry], {
85
110
  cwd: this.app.cwdPath,
86
- env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_RECYCLED: "1" } : {}) },
111
+ env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
87
112
  stdio: ["ignore", "inherit", "inherit"],
88
113
  ipc: (msg: BuilderMessage) => {
89
114
  if (this.#proc !== proc) return;
90
115
  if (!msg || typeof msg !== "object") return;
116
+ if (msg.type === "build-route-res" || msg.type === "build-csr-res") this.#inFlight.delete(msg.id);
91
117
  if (builderMsgTypeSet.has(msg.type)) this.#onMessage(msg);
92
118
  if (msg.type === "builder-ready" && !this.ready) {
93
119
  this.ready = true;
@@ -105,6 +131,11 @@ export class IncrementalBuilderHost {
105
131
  const wasRecycle = this.#recycleRequested;
106
132
  this.#clearRecycle();
107
133
  this.ready = false;
134
+ this.#failInFlight(
135
+ wasRecycle
136
+ ? "builder exited to release bundler memory before answering; reload to retry"
137
+ : "builder exited unexpectedly before answering; reload once it is back",
138
+ );
108
139
  if (this.#manualStop || this.#status === "stopped") return;
109
140
  if (!wasReady) {
110
141
  this.#status = "stopped";
@@ -189,6 +220,7 @@ export class IncrementalBuilderHost {
189
220
  }
190
221
  try {
191
222
  this.#proc.send(message);
223
+ if (message.type === "build-route" || message.type === "build-csr") this.#inFlight.set(message.id, message.type);
192
224
  return true;
193
225
  } catch (error) {
194
226
  this.logger.warn(
@@ -197,9 +229,24 @@ export class IncrementalBuilderHost {
197
229
  return false;
198
230
  }
199
231
  }
232
+ /**
233
+ * Answer every request the departing builder still owed, as if it had failed them itself. `onExit`
234
+ * cannot do this alone: `stop()` clears `#proc` first, so the exit callback bails on its identity check.
235
+ */
236
+ #failInFlight(reason: string): void {
237
+ if (!this.#inFlight.size) return;
238
+ const lost = [...this.#inFlight];
239
+ this.#inFlight.clear();
240
+ this.logger.warn(`failing ${lost.length} unanswered builder request(s): ${reason}`);
241
+ for (const [id, type] of lost) {
242
+ if (type === "build-route") this.#onMessage({ type: "build-route-res", id, ok: false, error: reason });
243
+ else this.#onMessage({ type: "build-csr-res", id, ok: false, error: reason });
244
+ }
245
+ }
200
246
  stop() {
201
247
  this.#manualStop = true;
202
248
  this.#clearRecycle();
249
+ this.#failInFlight("builder was stopped before answering");
203
250
  if (this.#restartTimer) {
204
251
  clearTimeout(this.#restartTimer);
205
252
  this.#restartTimer = null;
@@ -12,14 +12,12 @@ import {
12
12
  GraphClientEntryDiscovery,
13
13
  HmrWatcher,
14
14
  RouteClientBuilder,
15
- SsrBaseArtifactBuilder,
16
15
  WatchRootResolver,
17
16
  } from "@akanjs/devkit/frontendBuild";
18
17
  import { Logger } from "akanjs/common";
19
18
  import type {
20
19
  BaseBuildArtifact,
21
20
  BuilderCsrReq,
22
- BuilderCsrRes,
23
21
  BuilderMessage,
24
22
  BuilderReq,
25
23
  BuilderRes,
@@ -28,6 +26,7 @@ import type {
28
26
  } from "akanjs/server";
29
27
  import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
30
28
  import { BuildBatchRunner } from "./buildBatchRunner";
29
+ import { BuilderChannel } from "./builderChannel";
31
30
  import { prepareDevWatchBatch } from "./devWatchBatch";
32
31
 
33
32
  interface IncrementalBuilderOptions {
@@ -53,6 +52,7 @@ class IncrementalBuilder {
53
52
  #changePlanner: DevChangePlanner;
54
53
  #generatedIndexSync: DevGeneratedIndexSync;
55
54
  #autoImportSync: AutoImportSync;
55
+ #watcher: HmrWatcher | null = null;
56
56
  #generation = 0;
57
57
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
58
58
  #workQueue: Promise<void> = Promise.resolve();
@@ -82,8 +82,14 @@ class IncrementalBuilder {
82
82
  return `${this.#app.cwdPath}/.akan/artifact`;
83
83
  }
84
84
 
85
- async handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
86
- return this.#enqueueWork(`build-route:${msg.routeId}`, async () => this.#handleBuildRoute(msg));
85
+ /**
86
+ * Build a route and answer it. The reply is part of the work item on purpose: `shutdown` drains the
87
+ * work queue before exiting, so folding the flush in here is what makes "drained" mean "answered".
88
+ */
89
+ async handleBuildRoute(msg: BuilderReq): Promise<void> {
90
+ await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
91
+ BuilderChannel.send(await this.#handleBuildRoute(msg)),
92
+ );
87
93
  }
88
94
 
89
95
  async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
@@ -125,7 +131,7 @@ class IncrementalBuilder {
125
131
  { generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
126
132
  ): void {
127
133
  if (typeof generation !== "number") return;
128
- process.send?.({
134
+ BuilderChannel.emit({
129
135
  type: "build-status",
130
136
  data: {
131
137
  generation,
@@ -163,7 +169,7 @@ class IncrementalBuilder {
163
169
  */
164
170
  #reportMetrics(): void {
165
171
  if (!this.#idle || this.#shuttingDown) return;
166
- process.send?.({
172
+ BuilderChannel.emit({
167
173
  type: "builder-metrics",
168
174
  data: { rssBytes: process.memoryUsage.rss(), generation: this.#generation, workCount: this.#workCount },
169
175
  });
@@ -188,7 +194,14 @@ class IncrementalBuilder {
188
194
  }
189
195
  await this.#workQueue.catch(() => undefined);
190
196
  await this.#cssRebuildQueue.catch(() => undefined);
191
- this.#logger.info(`drained in ${Date.now() - started}ms; exiting for recycle`);
197
+ // Drained queues do not mean the host has the results. The events those work items produced are the
198
+ // largest messages this process sends, and `process.exit` discards an ipc write that has not
199
+ // flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
200
+ // anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
201
+ const flushed = await BuilderChannel.drain();
202
+ this.#logger.info(
203
+ `drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
204
+ );
192
205
  process.exit(0);
193
206
  }
194
207
 
@@ -260,7 +273,8 @@ class IncrementalBuilder {
260
273
  await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(appDir, artifactDir, batch));
261
274
  },
262
275
  });
263
- watcher.start();
276
+ await watcher.start();
277
+ this.#watcher = watcher;
264
278
  this.#logger.verbose(`watching ${roots.length} roots`);
265
279
  }
266
280
 
@@ -276,6 +290,10 @@ class IncrementalBuilder {
276
290
  if (autoImport.changedFiles.length > 0)
277
291
  this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
278
292
  const indexSync = await this.#generatedIndexSync.syncForBatch(batch.files);
293
+ //* Both passes above write source files, and this generation's build consumes what they wrote. Hand
294
+ //* them to the watcher so its verification scan does not read them back as a user edit and spend a
295
+ //* second generation rebuilding identical content.
296
+ await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
279
297
  const { files, kinds, expandedBatch, event, hasSyncErrors } = prepareDevWatchBatch({
280
298
  generation,
281
299
  batch,
@@ -299,7 +317,7 @@ class IncrementalBuilder {
299
317
 
300
318
  if (hasSyncErrors) {
301
319
  this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
302
- process.send?.(event);
320
+ BuilderChannel.emit(event);
303
321
  return;
304
322
  }
305
323
  if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
@@ -335,7 +353,7 @@ class IncrementalBuilder {
335
353
  needs.push("css");
336
354
  }
337
355
 
338
- process.send?.(event);
356
+ BuilderChannel.emit(event);
339
357
 
340
358
  if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
341
359
  // A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
@@ -362,15 +380,18 @@ class IncrementalBuilder {
362
380
  }): Promise<BuildBatchResult> {
363
381
  const started = Date.now();
364
382
  const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
365
- process.send?.(msg),
383
+ BuilderChannel.emit(msg),
366
384
  );
367
385
  if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
368
386
  if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
369
387
  // A worker that died before reporting streamed no build-status of its own, so report one per need
370
388
  // it was given: the generation must go red rather than look like it silently succeeded.
371
389
  if (result.crashed) {
390
+ // `base` is excluded because it is not a `BuildPhase`: a boot build has no phase board to fail, and
391
+ // it never travels through here — `#buildBootDeps` runs it and throws into the degraded-boot path.
372
392
  for (const need of needs)
373
- this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
393
+ if (need !== "base")
394
+ this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
374
395
  }
375
396
  if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
376
397
  return result;
@@ -401,7 +422,7 @@ class IncrementalBuilder {
401
422
 
402
423
  async boot(): Promise<void> {
403
424
  if (this.#watch) await this.installWatcher();
404
- process.send?.({ type: "builder-ready" });
425
+ BuilderChannel.emit({ type: "builder-ready" });
405
426
  this.#logger.verbose(`ready (watch=${this.#watch})`);
406
427
  }
407
428
 
@@ -429,7 +450,9 @@ class IncrementalBuilder {
429
450
  async announceBootState(): Promise<void> {
430
451
  const generation = ++this.#generation;
431
452
  const reason = "builder-recycle" as const;
432
- process.send?.({
453
+ // Awaited rather than emitted: this runs during a recycle, so the host may ask this builder to shut
454
+ // down at any moment, and "announced boot state" must mean the announcement left the process.
455
+ await BuilderChannel.send({
433
456
  type: "pages-updated",
434
457
  data: {
435
458
  bundlePath: this.#artifact.pagesBundlePath,
@@ -448,7 +471,7 @@ class IncrementalBuilder {
448
471
  ]),
449
472
  ),
450
473
  );
451
- process.send?.({
474
+ await BuilderChannel.send({
452
475
  type: "css-updated",
453
476
  data: { cssAssets, cssBase64ByUrl, generation, changedFiles: [], reason },
454
477
  });
@@ -460,8 +483,8 @@ class IncrementalBuilder {
460
483
  * dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
461
484
  * points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
462
485
  */
463
- async handleBuildCsr(msg: BuilderCsrReq): Promise<BuilderCsrRes> {
464
- return this.#enqueueWork("build-csr", async (): Promise<BuilderCsrRes> => {
486
+ async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
487
+ await this.#enqueueWork("build-csr", async (): Promise<void> => {
465
488
  const started = Date.now();
466
489
  // Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
467
490
  // never carried a csr status for it before. The error travels in the response below.
@@ -471,11 +494,12 @@ class IncrementalBuilder {
471
494
  const error = result.errors.csr;
472
495
  if (error) {
473
496
  this.#logger.error(`csr-build failed: ${error}`);
474
- return { type: "build-csr-res", id: msg.id, ok: false, error };
497
+ await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
498
+ return;
475
499
  }
476
500
  this.#csrActive = true;
477
501
  this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
478
- return { type: "build-csr-res", id: msg.id, ok: true };
502
+ await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
479
503
  });
480
504
  }
481
505
 
@@ -487,10 +511,40 @@ class IncrementalBuilder {
487
511
  return process.env.AKAN_DEV_CSR_REBUILD === "1";
488
512
  }
489
513
 
490
- static async #buildBootDeps(app: App): Promise<IncrementalBuilderBootDeps> {
491
- const { artifact, optimizedFonts } = await new SsrBaseArtifactBuilder(app).build();
514
+ /**
515
+ * Build the boot artifact in a process that exits afterwards, and keep only the serializable result.
516
+ *
517
+ * This runs in a worker for the same reason every other build does, and it was the largest single
518
+ * holdout: measured on `apps/akan`, `SsrBaseArtifactBuilder.build()` retains **+1143 MB** that
519
+ * `Bun.gc(true)` cannot touch, which is 65 % of the builder's post-boot RSS. Nothing was lost by
520
+ * moving it — the builder only ever kept `artifact` and `optimizedFonts`, both plain data, and the
521
+ * artifact is written to `base-artifact.json` regardless.
522
+ *
523
+ * `GraphClientEntryDiscovery.create` stays here because route builds need it live, and it costs
524
+ * nothing to keep: measured at **0 ms and 0 MB**, because it builds its graph lazily on first use.
525
+ */
526
+ static async #buildBootDeps(app: App, runner: BuildBatchRunner): Promise<IncrementalBuilderBootDeps> {
527
+ const result = await runner.run({
528
+ appName: app.name,
529
+ workspaceRoot: app.workspace.workspaceRoot,
530
+ repoName: app.workspace.repoName,
531
+ generation: 0,
532
+ needs: ["base"],
533
+ changedFiles: [],
534
+ // Discovered by the worker: at boot the watcher has no validated keys to seed, and the boot build
535
+ // globs them itself anyway.
536
+ pageKeys: null,
537
+ optimizedFonts: null,
538
+ cssAssets: null,
539
+ artifactDir: path.resolve(`${app.cwdPath}/.akan/artifact`),
540
+ });
541
+ // A failed boot build has to throw, not degrade quietly: `main` catches this to enter the degraded
542
+ // watch mode that keeps the dev server alive until the error is fixed.
543
+ if (result.errors.base) throw new Error(result.errors.base);
544
+ if (!result.artifact || !result.optimizedFonts)
545
+ throw new Error("boot build reported success without an artifact; the build worker likely died");
492
546
  const discovery = await GraphClientEntryDiscovery.create(app);
493
- return { artifact, optimizedFonts, discovery };
547
+ return { artifact: result.artifact, optimizedFonts: result.optimizedFonts, discovery };
494
548
  }
495
549
 
496
550
  /**
@@ -522,18 +576,19 @@ class IncrementalBuilder {
522
576
  app: App,
523
577
  bootError: unknown,
524
578
  logger: Logger,
579
+ runner: BuildBatchRunner,
525
580
  ): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
526
581
  const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
527
582
  logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
528
583
  let generation = 0;
529
584
  const sendFailure = (files: string[], message: string) => {
530
- process.send?.({
585
+ BuilderChannel.emit({
531
586
  type: "build-status",
532
587
  data: { generation, phase: "pages", ok: false, files, message: `Boot build failed: ${message}` },
533
588
  });
534
589
  };
535
590
  sendFailure([], firstMessage);
536
- process.send?.({ type: "builder-ready" });
591
+ BuilderChannel.emit({ type: "builder-ready" });
537
592
  return new Promise((resolve, reject) => {
538
593
  void (async () => {
539
594
  const roots = await new WatchRootResolver(app).resolve();
@@ -546,7 +601,7 @@ class IncrementalBuilder {
546
601
  try {
547
602
  // A broken akan.config.ts caches its import failure; re-import it before rebuilding.
548
603
  if (new Set(batch.kinds).has("config")) await app.getConfig({ refresh: true });
549
- const deps = await IncrementalBuilder.#buildBootDeps(app);
604
+ const deps = await IncrementalBuilder.#buildBootDeps(app, runner);
550
605
  const builder = new IncrementalBuilder({ app, watch: true, initialGeneration: generation, ...deps });
551
606
  watcher.stop();
552
607
  logger.info(`boot build recovered generation=${generation}`);
@@ -558,7 +613,7 @@ class IncrementalBuilder {
558
613
  }
559
614
  },
560
615
  });
561
- watcher.start();
616
+ await watcher.start();
562
617
  logger.warn(`[degraded] watching ${roots.length} roots for a fix`);
563
618
  })().catch(reject);
564
619
  });
@@ -589,39 +644,43 @@ class IncrementalBuilder {
589
644
  if (msg.type === "build-route") {
590
645
  const error = builder?.shuttingDown ? recyclingError : bootingError;
591
646
  if (!builder || builder.shuttingDown) {
592
- process.send?.({ type: "build-route-res", id: msg.id, ok: false, error });
647
+ BuilderChannel.emit({ type: "build-route-res", id: msg.id, ok: false, error });
593
648
  return;
594
649
  }
595
- void builder.handleBuildRoute(msg).then((res) => process.send?.(res));
650
+ void builder.handleBuildRoute(msg);
596
651
  return;
597
652
  }
598
653
  if (msg.type === "build-csr") {
599
654
  const error = builder?.shuttingDown ? recyclingError : bootingError;
600
655
  if (!builder || builder.shuttingDown) {
601
- process.send?.({ type: "build-csr-res", id: msg.id, ok: false, error });
656
+ BuilderChannel.emit({ type: "build-csr-res", id: msg.id, ok: false, error });
602
657
  return;
603
658
  }
604
- void builder.handleBuildCsr(msg).then((res) => process.send?.(res));
659
+ void builder.handleBuildCsr(msg);
605
660
  }
606
661
  });
607
662
  // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
608
- // as an orphaned watcher that keeps rebuilding for nobody.
663
+ // as an orphaned watcher that keeps rebuilding for nobody. Nothing is drained here on purpose —
664
+ // there is no longer anyone on the other end to flush to.
609
665
  process.on("disconnect", () => {
610
666
  logger.warn("host IPC channel closed; exiting builder");
611
667
  process.exit(0);
612
668
  });
613
669
  let recoveredFiles: string[] | null = null;
670
+ // Owned by `main` rather than the instance: the boot build has to run before an instance exists, and
671
+ // a degraded boot re-runs it once per file change until it succeeds.
672
+ const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
614
673
  try {
615
- builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app)) });
674
+ builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app, bootRunner)) });
616
675
  } catch (err) {
617
676
  if (!watch) throw err;
618
- const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger);
677
+ const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
619
678
  builder = recovered.builder;
620
679
  recoveredFiles = recovered.changedFiles;
621
680
  }
622
681
  await builder.boot();
623
682
  if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
624
- else if (process.env.AKAN_BUILDER_RECYCLED === "1") await builder.announceBootState();
683
+ else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
625
684
  await builder.rearmCsrFromEnv();
626
685
  }
627
686
  }