@akanjs/devkit 2.4.1-rc.4 → 2.4.1-rc.6

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.
@@ -4,7 +4,13 @@ import type { App } from "@akanjs/devkit/commandDecorators";
4
4
  // eager import is paid on every save. Measured on a 177-route app: `executors` 24ms, `frontendBuild`
5
5
  // ~110ms, app config 5ms.
6
6
  import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
7
- import { CsrArtifactBuilder, CssCompiler, FontOptimizer, PagesBundleBuilder } from "@akanjs/devkit/frontendBuild";
7
+ import {
8
+ CsrArtifactBuilder,
9
+ CssCompiler,
10
+ FontOptimizer,
11
+ PagesBundleBuilder,
12
+ SsrBaseArtifactBuilder,
13
+ } from "@akanjs/devkit/frontendBuild";
8
14
  import { Logger } from "akanjs/common";
9
15
  import type { BuilderMessage, BuildPhase } from "akanjs/server";
10
16
  import type { BuildBatchRequest, BuildBatchResult, OptimizedFonts, PagesBatchCssAssets } from "./buildBatchProtocol";
@@ -34,6 +40,8 @@ class BuildBatch {
34
40
  }
35
41
 
36
42
  async run(): Promise<BuildBatchResult> {
43
+ // `base` arrives alone, from a builder that cannot serve anything until it finishes.
44
+ if (this.#request.needs.includes("base")) await this.#buildBase();
37
45
  // Ordered the way the watcher used to run them: csr before pages so a csr failure cannot delay the
38
46
  // pages bundle the browser is waiting on, and css last because it depends on the rebuilt client.
39
47
  if (this.#request.needs.includes("csr")) await this.#buildCsr();
@@ -48,6 +56,12 @@ class BuildBatch {
48
56
  * never had, and it would move every artifact write into the window right before the watcher reports
49
57
  * the generation complete — which is where a save issued immediately afterwards gets dropped by Bun's
50
58
  * recursive `fs.watch` (`local/optimize-resource/06-watcher-dropped-event.md`).
59
+ *
60
+ * A bare `process.send` is safe here, unlike in the watcher, for one reason: this process ends by
61
+ * returning from `main`, and a natural exit flushes a pending ipc write (measured: 1MB delivered
62
+ * 20/20). It is `process.exit` that discards one — so adding an explicit exit to this file, at the end
63
+ * of `main` or anywhere after an emit, would silently start dropping `css-updated` payloads. Route
64
+ * sends through `BuilderChannel` if that ever becomes necessary.
51
65
  */
52
66
  #emit(message: BuilderMessage): void {
53
67
  process.send?.(message);
@@ -66,6 +80,25 @@ class BuildBatch {
66
80
  });
67
81
  }
68
82
 
83
+ /**
84
+ * The boot build. Streams nothing: a builder is not serving yet, so there is no phase board to update
85
+ * and no browser to reload — the watcher learns the outcome from the batch result, and a failure there
86
+ * is what puts it into degraded watch mode.
87
+ */
88
+ async #buildBase(): Promise<void> {
89
+ const started = Date.now();
90
+ try {
91
+ const { artifact, optimizedFonts } = await new SsrBaseArtifactBuilder(this.#app).build();
92
+ this.#result.artifact = artifact;
93
+ this.#result.optimizedFonts = optimizedFonts;
94
+ this.#logger.verbose(`base-artifact ok buildId=${artifact.pagesBundleBuildId} (${Date.now() - started}ms)`);
95
+ } catch (err) {
96
+ const message = err instanceof Error ? err.message : String(err);
97
+ this.#logger.error(`base-artifact failed: ${message}`);
98
+ this.#result.errors.base = message;
99
+ }
100
+ }
101
+
69
102
  async #buildCsr(): Promise<void> {
70
103
  const started = Date.now();
71
104
  try {
@@ -1,9 +1,15 @@
1
1
  import type { FontOptimizer } from "@akanjs/devkit/frontendBuild";
2
- import type { CssPayload, PagesBundlePayload } from "akanjs/server";
2
+ import type { BaseBuildArtifact, CssPayload, PagesBundlePayload } from "akanjs/server";
3
3
 
4
4
  export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
5
5
 
6
- export type BuildBatchNeed = "pages" | "css" | "csr";
6
+ /**
7
+ * `base` is the boot build (`SsrBaseArtifactBuilder`) and is never batched with the others: it is what a
8
+ * builder runs before it can serve anything, where the rest are per-save work. It earns its place here
9
+ * for the same reason as the others — measured on `apps/akan`, it retains **+1143 MB** of bundler arena
10
+ * that only a process exit returns, which was 65 % of the builder's whole idle footprint.
11
+ */
12
+ export type BuildBatchNeed = "base" | "pages" | "css" | "csr";
7
13
 
8
14
  /**
9
15
  * One generation of build work, handed to a process that exits when it is done.
@@ -44,6 +50,11 @@ export interface BuildBatchResult {
44
50
  generation: number;
45
51
  cssAssets?: PagesBatchCssAssets;
46
52
  optimizedFonts?: OptimizedFonts;
53
+ /**
54
+ * Present only for a `base` batch. The same object the worker wrote to `base-artifact.json`, returned
55
+ * here so the watcher does not have to read its own boot build back off disk.
56
+ */
57
+ artifact?: BaseBuildArtifact;
47
58
  errors: Partial<Record<BuildBatchNeed, string>>;
48
59
  /** The worker died before reporting, so it streamed no `build-status` of its own for these needs. */
49
60
  crashed?: boolean;
@@ -0,0 +1,144 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import { mkdtemp, rm } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { BuilderChannel } from "./builderChannel";
6
+
7
+ const tempDirs: string[] = [];
8
+
9
+ afterEach(async () => {
10
+ for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true });
11
+ });
12
+
13
+ type SendMode = "drain" | "await" | "bare";
14
+
15
+ /**
16
+ * Run a child that sends what the builder sends and then exits the way the recycle drain does, and
17
+ * report what the parent actually received.
18
+ *
19
+ * `payload` picks the message shape, because shape decides whether a bare send survives: `manifest` is a
20
+ * map of many short strings (a `build-route-res` delta), `css` is one long string (a base64 stylesheet).
21
+ */
22
+ const sendThenExit = async (
23
+ bytes: number,
24
+ { mode, payload }: { mode: SendMode; payload: "manifest" | "css" },
25
+ ): Promise<Array<{ type?: string }>> => {
26
+ const dir = await mkdtemp(path.join(os.tmpdir(), "builder-channel-"));
27
+ tempDirs.push(dir);
28
+ const entry = path.join(dir, "child.ts");
29
+ const modulePath = JSON.stringify(path.join(import.meta.dir, "builderChannel"));
30
+ const build =
31
+ payload === "manifest"
32
+ ? [
33
+ 'const chunk = "x".repeat(200);',
34
+ "const moduleMap: Record<string, string> = {};",
35
+ `for (let i = 0; i * 210 < ${bytes}; i++) moduleMap["chunk-" + i] = chunk;`,
36
+ 'const msg = { type: "build-route-res", id: 7, ok: true, data: { ssrManifestDelta: moduleMap } };',
37
+ ]
38
+ : [
39
+ `const css = "y".repeat(${bytes});`,
40
+ 'const msg = { type: "css-updated", data: { cssAssets: {}, cssBase64ByUrl: { "/a.css": css } } };',
41
+ ];
42
+ const sendLine = {
43
+ drain: "BuilderChannel.emit(msg as never);\nawait BuilderChannel.drain();",
44
+ await: "await BuilderChannel.send(msg as never);",
45
+ bare: "process.send?.(msg);",
46
+ }[mode];
47
+ await Bun.write(
48
+ entry,
49
+ [`import { BuilderChannel } from ${modulePath};`, ...build, sendLine, "process.exit(0);"].join("\n"),
50
+ );
51
+ const received: Array<{ type?: string }> = [];
52
+ const proc = Bun.spawn(["bun", entry], {
53
+ stdio: ["ignore", "inherit", "inherit"],
54
+ // The mode the dev host actually spawns the builder with; it changes where the loss cliff sits.
55
+ serialization: "advanced",
56
+ ipc: (message) => {
57
+ received.push(message as { type?: string });
58
+ },
59
+ });
60
+ await proc.exited;
61
+ // Messages land before the exit callback, never after, but leave room for a straggler to prove it.
62
+ await Bun.sleep(50);
63
+ return received;
64
+ };
65
+
66
+ describe("BuilderChannel", () => {
67
+ test("delivers a reply too large for the pipe buffer before the process exits", async () => {
68
+ expect(await sendThenExit(1_000_000, { mode: "await", payload: "manifest" })).toMatchObject([{ id: 7 }]);
69
+ // A manifest delta is usually well past the buffer, but the small case must keep working too.
70
+ expect(await sendThenExit(0, { mode: "await", payload: "manifest" })).toMatchObject([{ id: 7 }]);
71
+ });
72
+
73
+ test("a drained event survives the exit even though nobody awaited it", async () => {
74
+ // The event path: `css-updated` is relayed with no caller to await it, and the recycle drain exits
75
+ // milliseconds later. Only `drain()` stands between the two.
76
+ expect(await sendThenExit(200_000, { mode: "drain", payload: "css" })).toMatchObject([{ type: "css-updated" }]);
77
+ });
78
+
79
+ test("without the flush wait the same messages are lost, which is why this class exists", async () => {
80
+ // Controls, not requirements: if a future bun flushes ipc writes on exit, these fail and say so.
81
+ expect(await sendThenExit(1_000_000, { mode: "bare", payload: "manifest" })).toEqual([]);
82
+ // 20KB of css, far below the 64KB the manifest shape survives — one long string dies much earlier,
83
+ // so no size threshold would have been safe to special-case.
84
+ expect(await sendThenExit(20_000, { mode: "bare", payload: "css" })).toEqual([]);
85
+ });
86
+
87
+ test("drain resolves only once every tracked send has flushed", async () => {
88
+ const send = process.send;
89
+ const flushes: Array<() => void> = [];
90
+ try {
91
+ (process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
92
+ flushes.push(cb);
93
+ return true;
94
+ };
95
+ BuilderChannel.emit({ type: "builder-ready" });
96
+ BuilderChannel.emit({ type: "builder-ready" });
97
+ let drained = false;
98
+ const draining = BuilderChannel.drain().then((count) => {
99
+ drained = true;
100
+ return count;
101
+ });
102
+ flushes[0]?.();
103
+ await Bun.sleep(1);
104
+ expect(drained).toBe(false);
105
+ flushes[1]?.();
106
+ expect(await draining).toBe(2);
107
+ } finally {
108
+ (process as { send?: unknown }).send = send;
109
+ }
110
+ });
111
+
112
+ test("a send started while draining is drained too", async () => {
113
+ const send = process.send;
114
+ const flushes: Array<() => void> = [];
115
+ try {
116
+ (process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
117
+ flushes.push(cb);
118
+ return true;
119
+ };
120
+ BuilderChannel.emit({ type: "builder-ready" });
121
+ const draining = BuilderChannel.drain();
122
+ // What `#reportMetrics` does from a work item's `finally`, after the drain has begun.
123
+ BuilderChannel.emit({ type: "builder-metrics", data: { rssBytes: 1, generation: 1, workCount: 1 } });
124
+ for (let i = 0; i < 4 && flushes.length; i++) {
125
+ flushes.shift()?.();
126
+ await Bun.sleep(1);
127
+ }
128
+ expect(await draining).toBe(2);
129
+ } finally {
130
+ (process as { send?: unknown }).send = send;
131
+ }
132
+ });
133
+
134
+ test("resolves instead of hanging when there is no ipc channel", async () => {
135
+ const send = process.send;
136
+ try {
137
+ (process as { send?: typeof process.send }).send = undefined;
138
+ await BuilderChannel.send({ type: "build-csr-res", id: 1, ok: true });
139
+ expect(await BuilderChannel.drain()).toBe(0);
140
+ } finally {
141
+ (process as { send?: typeof process.send }).send = send;
142
+ }
143
+ });
144
+ });
@@ -0,0 +1,72 @@
1
+ import type { BuilderMessage } from "akanjs/server";
2
+
3
+ /**
4
+ * Every message the builder sends its host, and the record of which of those writes have actually left
5
+ * the process.
6
+ *
7
+ * `process.exit` discards an ipc write that has not flushed, and this process exits routinely — the
8
+ * recycle that keeps its RSS under the ceiling ends in `process.exit(0)`. Whether a given message
9
+ * survives that depends on its *shape*, not only its size. Measured on bun 1.3.14 (darwin,
10
+ * `serialization: "advanced"`), send-then-exit-immediately, 20 rounds each:
11
+ *
12
+ * - one long string: 8KB delivered 20/20, 16KB delivered **0/20**
13
+ * - many short strings: 60KB delivered 20/20, 100KB delivered **0/20**
14
+ *
15
+ * `css-updated` is the first shape (a base64 stylesheet per url) and `build-route-res` the second (a
16
+ * manifest delta), so neither sits safely under a threshold worth relying on. Nothing here assumes one:
17
+ * every send reports when it flushed, and `drain` is what `shutdown` awaits before exiting.
18
+ *
19
+ * A natural exit does *not* have this problem — a process that simply returns from `main` flushes 1MB
20
+ * 20/20 — which is why the disposable build worker needs none of this. Only an explicit `process.exit`
21
+ * cuts a write short.
22
+ */
23
+ export class BuilderChannel {
24
+ /**
25
+ * Bound so a runtime that ever stops invoking the flush callback costs one late message instead of
26
+ * wedging the shutdown drain until the host's kill watchdog fires.
27
+ */
28
+ static readonly #flushTimeoutMs = 5_000;
29
+ /** Sends that have not flushed yet; `drain` only ever needs to await them all. */
30
+ static readonly #flushing = new Set<Promise<void>>();
31
+
32
+ /** Send, and resolve once the write has left this process. */
33
+ static send(message: BuilderMessage): Promise<void> {
34
+ const send = process.send;
35
+ if (!send) return Promise.resolve();
36
+ const flushed = new Promise<void>((resolve) => {
37
+ const timer = setTimeout(resolve, BuilderChannel.#flushTimeoutMs);
38
+ // A failed send has nothing to retry: the host answers an unanswered request id itself when this
39
+ // process exits, and a lost event is superseded by the next generation's.
40
+ send.call(process, message, undefined, undefined, () => {
41
+ clearTimeout(timer);
42
+ resolve();
43
+ });
44
+ });
45
+ BuilderChannel.#flushing.add(flushed);
46
+ void flushed.then(() => BuilderChannel.#flushing.delete(flushed));
47
+ return flushed;
48
+ }
49
+
50
+ /**
51
+ * Send an event no caller awaits. Tracked exactly like `send`, which is the whole point: a relayed
52
+ * `css-updated` has nobody to await it, so `drain` is the only thing standing between it and the exit.
53
+ */
54
+ static emit(message: BuilderMessage): void {
55
+ void BuilderChannel.send(message);
56
+ }
57
+
58
+ /**
59
+ * Resolve once every send handed over so far has flushed, and report how many were still in flight.
60
+ * Sends started *while* draining are covered too — `#reportMetrics` can fire from a work item's
61
+ * `finally`, after the drain has already begun.
62
+ */
63
+ static async drain(): Promise<number> {
64
+ let flushed = 0;
65
+ while (BuilderChannel.#flushing.size) {
66
+ const pending = [...BuilderChannel.#flushing];
67
+ flushed += pending.length;
68
+ await Promise.all(pending);
69
+ }
70
+ return flushed;
71
+ }
72
+ }
@@ -12,7 +12,6 @@ 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";
@@ -27,7 +26,7 @@ import type {
27
26
  } from "akanjs/server";
28
27
  import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
29
28
  import { BuildBatchRunner } from "./buildBatchRunner";
30
- import { BuilderReply } from "./builderReply";
29
+ import { BuilderChannel } from "./builderChannel";
31
30
  import { prepareDevWatchBatch } from "./devWatchBatch";
32
31
 
33
32
  interface IncrementalBuilderOptions {
@@ -89,7 +88,7 @@ class IncrementalBuilder {
89
88
  */
90
89
  async handleBuildRoute(msg: BuilderReq): Promise<void> {
91
90
  await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
92
- BuilderReply.send(await this.#handleBuildRoute(msg)),
91
+ BuilderChannel.send(await this.#handleBuildRoute(msg)),
93
92
  );
94
93
  }
95
94
 
@@ -132,7 +131,7 @@ class IncrementalBuilder {
132
131
  { generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
133
132
  ): void {
134
133
  if (typeof generation !== "number") return;
135
- process.send?.({
134
+ BuilderChannel.emit({
136
135
  type: "build-status",
137
136
  data: {
138
137
  generation,
@@ -170,7 +169,7 @@ class IncrementalBuilder {
170
169
  */
171
170
  #reportMetrics(): void {
172
171
  if (!this.#idle || this.#shuttingDown) return;
173
- process.send?.({
172
+ BuilderChannel.emit({
174
173
  type: "builder-metrics",
175
174
  data: { rssBytes: process.memoryUsage.rss(), generation: this.#generation, workCount: this.#workCount },
176
175
  });
@@ -195,7 +194,14 @@ class IncrementalBuilder {
195
194
  }
196
195
  await this.#workQueue.catch(() => undefined);
197
196
  await this.#cssRebuildQueue.catch(() => undefined);
198
- 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
+ );
199
205
  process.exit(0);
200
206
  }
201
207
 
@@ -311,7 +317,7 @@ class IncrementalBuilder {
311
317
 
312
318
  if (hasSyncErrors) {
313
319
  this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
314
- process.send?.(event);
320
+ BuilderChannel.emit(event);
315
321
  return;
316
322
  }
317
323
  if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
@@ -347,7 +353,7 @@ class IncrementalBuilder {
347
353
  needs.push("css");
348
354
  }
349
355
 
350
- process.send?.(event);
356
+ BuilderChannel.emit(event);
351
357
 
352
358
  if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
353
359
  // A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
@@ -374,15 +380,18 @@ class IncrementalBuilder {
374
380
  }): Promise<BuildBatchResult> {
375
381
  const started = Date.now();
376
382
  const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
377
- process.send?.(msg),
383
+ BuilderChannel.emit(msg),
378
384
  );
379
385
  if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
380
386
  if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
381
387
  // A worker that died before reporting streamed no build-status of its own, so report one per need
382
388
  // it was given: the generation must go red rather than look like it silently succeeded.
383
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.
384
392
  for (const need of needs)
385
- 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] });
386
395
  }
387
396
  if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
388
397
  return result;
@@ -413,7 +422,7 @@ class IncrementalBuilder {
413
422
 
414
423
  async boot(): Promise<void> {
415
424
  if (this.#watch) await this.installWatcher();
416
- process.send?.({ type: "builder-ready" });
425
+ BuilderChannel.emit({ type: "builder-ready" });
417
426
  this.#logger.verbose(`ready (watch=${this.#watch})`);
418
427
  }
419
428
 
@@ -441,7 +450,9 @@ class IncrementalBuilder {
441
450
  async announceBootState(): Promise<void> {
442
451
  const generation = ++this.#generation;
443
452
  const reason = "builder-recycle" as const;
444
- 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({
445
456
  type: "pages-updated",
446
457
  data: {
447
458
  bundlePath: this.#artifact.pagesBundlePath,
@@ -460,7 +471,7 @@ class IncrementalBuilder {
460
471
  ]),
461
472
  ),
462
473
  );
463
- process.send?.({
474
+ await BuilderChannel.send({
464
475
  type: "css-updated",
465
476
  data: { cssAssets, cssBase64ByUrl, generation, changedFiles: [], reason },
466
477
  });
@@ -483,12 +494,12 @@ class IncrementalBuilder {
483
494
  const error = result.errors.csr;
484
495
  if (error) {
485
496
  this.#logger.error(`csr-build failed: ${error}`);
486
- await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: false, error });
497
+ await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
487
498
  return;
488
499
  }
489
500
  this.#csrActive = true;
490
501
  this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
491
- await BuilderReply.send({ type: "build-csr-res", id: msg.id, ok: true });
502
+ await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
492
503
  });
493
504
  }
494
505
 
@@ -500,10 +511,40 @@ class IncrementalBuilder {
500
511
  return process.env.AKAN_DEV_CSR_REBUILD === "1";
501
512
  }
502
513
 
503
- static async #buildBootDeps(app: App): Promise<IncrementalBuilderBootDeps> {
504
- 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");
505
546
  const discovery = await GraphClientEntryDiscovery.create(app);
506
- return { artifact, optimizedFonts, discovery };
547
+ return { artifact: result.artifact, optimizedFonts: result.optimizedFonts, discovery };
507
548
  }
508
549
 
509
550
  /**
@@ -535,18 +576,19 @@ class IncrementalBuilder {
535
576
  app: App,
536
577
  bootError: unknown,
537
578
  logger: Logger,
579
+ runner: BuildBatchRunner,
538
580
  ): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
539
581
  const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
540
582
  logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
541
583
  let generation = 0;
542
584
  const sendFailure = (files: string[], message: string) => {
543
- process.send?.({
585
+ BuilderChannel.emit({
544
586
  type: "build-status",
545
587
  data: { generation, phase: "pages", ok: false, files, message: `Boot build failed: ${message}` },
546
588
  });
547
589
  };
548
590
  sendFailure([], firstMessage);
549
- process.send?.({ type: "builder-ready" });
591
+ BuilderChannel.emit({ type: "builder-ready" });
550
592
  return new Promise((resolve, reject) => {
551
593
  void (async () => {
552
594
  const roots = await new WatchRootResolver(app).resolve();
@@ -559,7 +601,7 @@ class IncrementalBuilder {
559
601
  try {
560
602
  // A broken akan.config.ts caches its import failure; re-import it before rebuilding.
561
603
  if (new Set(batch.kinds).has("config")) await app.getConfig({ refresh: true });
562
- const deps = await IncrementalBuilder.#buildBootDeps(app);
604
+ const deps = await IncrementalBuilder.#buildBootDeps(app, runner);
563
605
  const builder = new IncrementalBuilder({ app, watch: true, initialGeneration: generation, ...deps });
564
606
  watcher.stop();
565
607
  logger.info(`boot build recovered generation=${generation}`);
@@ -602,7 +644,7 @@ class IncrementalBuilder {
602
644
  if (msg.type === "build-route") {
603
645
  const error = builder?.shuttingDown ? recyclingError : bootingError;
604
646
  if (!builder || builder.shuttingDown) {
605
- 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 });
606
648
  return;
607
649
  }
608
650
  void builder.handleBuildRoute(msg);
@@ -611,24 +653,28 @@ class IncrementalBuilder {
611
653
  if (msg.type === "build-csr") {
612
654
  const error = builder?.shuttingDown ? recyclingError : bootingError;
613
655
  if (!builder || builder.shuttingDown) {
614
- 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 });
615
657
  return;
616
658
  }
617
659
  void builder.handleBuildCsr(msg);
618
660
  }
619
661
  });
620
662
  // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
621
- // 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.
622
665
  process.on("disconnect", () => {
623
666
  logger.warn("host IPC channel closed; exiting builder");
624
667
  process.exit(0);
625
668
  });
626
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 });
627
673
  try {
628
- builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app)) });
674
+ builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app, bootRunner)) });
629
675
  } catch (err) {
630
676
  if (!watch) throw err;
631
- const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger);
677
+ const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
632
678
  builder = recovered.builder;
633
679
  recoveredFiles = recovered.changedFiles;
634
680
  }
@@ -566,9 +566,21 @@ describe("dev resource budgets", () => {
566
566
  const idleBuilder = await DevStabilityHarness.builderProcess(host.proc.pid);
567
567
  // Nothing should be building at idle, so the disposable worker must not be resident.
568
568
  expect(await DevStabilityHarness.buildWorkerProcess(host.proc.pid)).toBeNull();
569
- // Measured ~670MB for this fixture; the headroom covers machine variance, not a reintroduced
570
- // eager import (the cheapest of those is ~30MB, and the devkit barrel cycle was 236MB).
571
- expect(idleTotal).toBeLessThan(1_000 * MB);
569
+ // Printed so a run that came close to its budget says so, instead of being indistinguishable from a
570
+ // comfortable one the same reason the recycle and idle-suspend guards report their numbers.
571
+ console.info(
572
+ `[budget-guard] idle tree ${Math.round(idleTotal / MB)}MB (builder ${Math.round((idleBuilder?.rssBytes ?? 0) / MB)}MB, rest ${Math.round(idleWithoutBuilder / MB)}MB)`,
573
+ );
574
+ // Split in two because the two halves have very different variance. The builder legitimately swings
575
+ // from a ~130MB fresh floor to ~520MB once it has built a route (`Bun.build` arenas the RSS-ceiling
576
+ // recycle is what bounds), so a tight total would flake. Measured 929-959MB total / ~414MB without
577
+ // the builder; the old 1000MB total came from a phase-1 topology where the fixture measured ~670MB
578
+ // and is only ~4% above what the current topology legitimately uses.
579
+ expect(idleTotal).toBeLessThan(1_200 * MB);
580
+ // This is the half that catches what the budget exists for: an eager import lands in the dev host or
581
+ // the backend, not in the builder's arenas. The cheapest reintroduction is ~30MB and the devkit
582
+ // barrel cycle was 236MB, so this bound still fails on the latter.
583
+ expect(idleWithoutBuilder).toBeLessThan(600 * MB);
572
584
 
573
585
  const start = host.markLog();
574
586
  for (let i = 1; i <= 3; i++) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.4.1-rc.4",
3
+ "version": "2.4.1-rc.6",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -44,7 +44,7 @@
44
44
  "@langchain/openai": "^1.4.6",
45
45
  "@tailwindcss/node": "^4.3.0",
46
46
  "@trapezedev/project": "^7.1.4",
47
- "akanjs": "2.4.1-rc.4",
47
+ "akanjs": "2.4.1-rc.6",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "daisyui": "5.5.23",
@@ -1,73 +0,0 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import os from "node:os";
4
- import path from "node:path";
5
- import { BuilderReply } from "./builderReply";
6
-
7
- const tempDirs: string[] = [];
8
-
9
- afterEach(async () => {
10
- for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true });
11
- });
12
-
13
- /**
14
- * Run a child that answers a build request and exits immediately — the shape of the recycle drain
15
- * finishing its last work item — and report whatever the parent actually received.
16
- */
17
- const replyThenExit = async (
18
- bytes: number,
19
- { awaitFlush }: { awaitFlush: boolean },
20
- ): Promise<{ id?: number } | null> => {
21
- const dir = await mkdtemp(path.join(os.tmpdir(), "builder-reply-"));
22
- tempDirs.push(dir);
23
- const entry = path.join(dir, "child.ts");
24
- const modulePath = JSON.stringify(path.join(import.meta.dir, "builderReply"));
25
- const reply = awaitFlush ? "await BuilderReply.send(res as never);" : "process.send?.(res);";
26
- await Bun.write(
27
- entry,
28
- [
29
- `import { BuilderReply } from ${modulePath};`,
30
- 'const chunk = "x".repeat(200);',
31
- "const moduleMap: Record<string, string> = {};",
32
- `for (let i = 0; i * 210 < ${bytes}; i++) moduleMap["chunk-" + i] = chunk;`,
33
- 'const res = { type: "build-route-res", id: 7, ok: true, data: { ssrManifestDelta: moduleMap } };',
34
- reply,
35
- "process.exit(0);",
36
- ].join("\n"),
37
- );
38
- let received: { id?: number } | null = null;
39
- const proc = Bun.spawn(["bun", entry], {
40
- stdio: ["ignore", "inherit", "inherit"],
41
- serialization: "advanced",
42
- ipc: (message) => {
43
- received = message as { id?: number };
44
- },
45
- });
46
- await proc.exited;
47
- // Replies land before the exit callback, never after, but leave room for a straggler to prove it.
48
- await Bun.sleep(50);
49
- return received;
50
- };
51
-
52
- describe("BuilderReply.send", () => {
53
- test("delivers a reply too large for the pipe buffer before the process exits", async () => {
54
- expect(await replyThenExit(1_000_000, { awaitFlush: true })).toMatchObject({ id: 7 });
55
- // A manifest delta is usually well past the buffer, but the small case must keep working too.
56
- expect(await replyThenExit(0, { awaitFlush: true })).toMatchObject({ id: 7 });
57
- });
58
-
59
- test("without the flush wait the same reply is lost, which is why this class exists", async () => {
60
- // A control, not a requirement: if a future bun flushes ipc writes on exit, this fails and says so.
61
- expect(await replyThenExit(1_000_000, { awaitFlush: false })).toBeNull();
62
- });
63
-
64
- test("resolves instead of hanging when there is no ipc channel", async () => {
65
- const send = process.send;
66
- try {
67
- (process as { send?: typeof process.send }).send = undefined;
68
- await BuilderReply.send({ type: "build-csr-res", id: 1, ok: true });
69
- } finally {
70
- (process as { send?: typeof process.send }).send = send;
71
- }
72
- });
73
- });
@@ -1,30 +0,0 @@
1
- import type { BuilderCsrRes, BuilderRes } from "akanjs/server";
2
-
3
- /**
4
- * Sends a builder's answer to a backend request and reports when it has actually left the process.
5
- *
6
- * `process.exit` truncates an ipc write that has not flushed yet, and anything past the pipe buffer
7
- * (~64KiB) needs the sender to stay alive to drain it — a `build-route-res` carrying a manifest delta is
8
- * routinely larger than that. Measured on bun 1.3: a 100KB reply sent immediately before
9
- * `process.exit(0)` was lost 20/20 times, and arrived 20/20 times when the exit waited for the flush
10
- * callback. That made the recycle drain, which exists to release bundler memory, eat the answer of
11
- * whatever route build it happened to be draining.
12
- */
13
- export class BuilderReply {
14
- /** Bound so a runtime that ever stops invoking the callback costs one late reply instead of wedging
15
- * the shutdown drain until the host's kill watchdog fires. */
16
- static readonly #flushTimeoutMs = 5_000;
17
-
18
- static async send(res: BuilderRes | BuilderCsrRes): Promise<void> {
19
- const send = process.send;
20
- if (!send) return;
21
- await new Promise<void>((resolve) => {
22
- const timer = setTimeout(resolve, BuilderReply.#flushTimeoutMs);
23
- // A failed send has nothing to retry — the host answers the id itself when this process exits.
24
- send.call(process, res, undefined, undefined, () => {
25
- clearTimeout(timer);
26
- resolve();
27
- });
28
- });
29
- }
30
- }