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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/akanApp/akanApp.host.ts +113 -23
  3. package/akanApp/devBootLatch.test.ts +39 -0
  4. package/akanApp/devBootLatch.ts +39 -0
  5. package/akanApp/devHostPolicy.test.ts +71 -1
  6. package/akanApp/devHostPolicy.ts +29 -4
  7. package/cloud/globalConfig.ts +3 -17
  8. package/csrE2e/csrE2eHarness.fixture.ts +66 -11
  9. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  10. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  11. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  12. package/fileSys.ts +16 -1
  13. package/frontendBuild/buildRouteClient.test.ts +61 -0
  14. package/frontendBuild/bundleDefine.test.ts +12 -0
  15. package/frontendBuild/bundleDefine.ts +5 -1
  16. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  17. package/frontendBuild/clientEntryDiscovery.ts +117 -70
  18. package/frontendBuild/csrArtifactBuilder.ts +3 -6
  19. package/frontendBuild/csrDevArtifactWriter.test.ts +143 -0
  20. package/frontendBuild/csrDevArtifactWriter.ts +158 -24
  21. package/frontendBuild/csrDevBundler.ts +44 -205
  22. package/frontendBuild/csrDevModuleCompiler.test.ts +89 -0
  23. package/frontendBuild/csrDevModuleCompiler.ts +56 -10
  24. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  25. package/frontendBuild/csrDevPatcher.ts +548 -0
  26. package/frontendBuild/csrDevPaths.ts +81 -1
  27. package/frontendBuild/csrDevResolver.test.ts +239 -0
  28. package/frontendBuild/csrDevResolver.ts +119 -18
  29. package/frontendBuild/csrDevTypes.ts +16 -1
  30. package/frontendBuild/csrEntryFiles.ts +24 -0
  31. package/frontendBuild/cssCompiler.ts +2 -4
  32. package/frontendBuild/devRegistryBundler.ts +192 -0
  33. package/frontendBuild/hmrWatcher.test.ts +51 -0
  34. package/frontendBuild/hmrWatcher.ts +17 -3
  35. package/frontendBuild/index.ts +3 -0
  36. package/frontendBuild/pagesBundleBuilder.ts +24 -1
  37. package/frontendBuild/pagesEntrySourceGenerator.ts +9 -2
  38. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  39. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  40. package/frontendBuild/routeClientBuilder.ts +79 -18
  41. package/frontendBuild/serverGraphFile.test.ts +77 -0
  42. package/frontendBuild/serverGraphFile.ts +93 -0
  43. package/frontendBuild/ssrDevBundler.ts +85 -0
  44. package/incrementalBuilder/buildBatch.proc.ts +94 -16
  45. package/incrementalBuilder/buildBatchProtocol.ts +6 -3
  46. package/incrementalBuilder/buildBatchRunner.test.ts +31 -0
  47. package/incrementalBuilder/buildBatchRunner.ts +17 -8
  48. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  49. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  50. package/incrementalBuilder/incrementalBuilder.host.test.ts +35 -1
  51. package/incrementalBuilder/incrementalBuilder.host.ts +50 -6
  52. package/incrementalBuilder/incrementalBuilder.proc.test.ts +190 -2
  53. package/incrementalBuilder/incrementalBuilder.proc.ts +560 -60
  54. package/integration/devStability.integration.test.ts +25 -7
  55. package/package.json +2 -2
  56. package/transforms/rscUseClientTransform.ts +7 -0
  57. package/transforms/transforms.test.ts +13 -0
  58. package/transforms/useClientBundlePlugin.ts +4 -1
@@ -31,4 +31,35 @@ describe("BuildBatchRunner", () => {
31
31
 
32
32
  expect(result.errors.base).toContain('"./not-there" (apps/demo/page/_index.tsx:1:22)');
33
33
  }, 30_000);
34
+
35
+ test("a worker that dies mid-batch keeps what each need reported, and fails only the needs it never reached", async () => {
36
+ const { root } = track(await createTempApp("demo"));
37
+ const appDir = path.join(root, "apps/demo");
38
+ await writeText(
39
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
40
+ `const { generation } = JSON.parse(process.argv[2]);
41
+ const status = (phase, message) =>
42
+ process.send({ type: "build-status", data: { generation, phase, ok: !message, files: [], message } });
43
+ status("ssr");
44
+ status("css", "css broke");
45
+ setTimeout(() => process.exit(1), 50);
46
+ `,
47
+ );
48
+ const result = await new BuildBatchRunner({ workspaceRoot: root, cwd: appDir }).run({
49
+ appName: "demo",
50
+ workspaceRoot: root,
51
+ repoName: "repo",
52
+ generation: 7,
53
+ needs: ["ssr", "css", "pages"],
54
+ changedFiles: [],
55
+ pageKeys: null,
56
+ optimizedFonts: null,
57
+ cssAssets: null,
58
+ artifactDir: path.join(appDir, ".akan/artifact"),
59
+ });
60
+ expect(result).toMatchObject({ crashed: true, crashedNeeds: ["pages"] });
61
+ expect(result.errors.ssr).toBeUndefined();
62
+ expect(result.errors.css).toBe("css broke");
63
+ expect(result.errors.pages).toContain("exited with code 1");
64
+ }, 30_000);
34
65
  });
@@ -3,7 +3,8 @@ import { Logger } from "akanjs/common";
3
3
  import type { BuilderMessage } from "akanjs/server";
4
4
  import type { BuildBatchMessage, BuildBatchRequest, BuildBatchResult } from "./buildBatchProtocol";
5
5
 
6
- // No pool on purpose: one worker at a time, exiting after its batch, returns the arenas `Bun.build` never frees.
6
+ // No pool on purpose: a worker exiting after its batch returns the arenas `Bun.build` never frees. The callers order
7
+ // their own work (the slow lane, the css queue, the SSR registry's boot build beside them), so a few can overlap.
7
8
  // A worker that dies without reporting must not take the watcher down; its needs come back as errors.
8
9
  export class BuildBatchRunner {
9
10
  #logger = new Logger("BuildBatchRunner");
@@ -38,8 +39,10 @@ export class BuildBatchRunner {
38
39
  const started = Date.now();
39
40
  const entry = await this.#resolveEntry();
40
41
  let result: BuildBatchResult | null = null;
42
+ const reported = new Map<string, string | undefined>();
43
+ const payload = request.trace ? { ...request, trace: { ...request.trace, spawnAt: Date.now() } } : request;
41
44
  // argv rather than IPC, so the worker starts on its first tick instead of waiting for a handshake.
42
- const proc = Bun.spawn(["bun", entry, JSON.stringify(request)], {
45
+ const proc = Bun.spawn(["bun", entry, JSON.stringify(payload)], {
43
46
  cwd: this.#cwd,
44
47
  env: process.env,
45
48
  stdio: ["ignore", "inherit", "inherit"],
@@ -47,7 +50,10 @@ export class BuildBatchRunner {
47
50
  ipc: (message: BuildBatchMessage | BuilderMessage) => {
48
51
  if (!message || typeof message !== "object") return;
49
52
  if (message.type === "build-batch-result") result = message.data;
50
- else onMessage(message);
53
+ else {
54
+ if (message.type === "build-status") reported.set(message.data.phase, message.data.message);
55
+ onMessage(message);
56
+ }
51
57
  },
52
58
  });
53
59
  const exitCode = await proc.exited;
@@ -64,10 +70,13 @@ export class BuildBatchRunner {
64
70
  }`
65
71
  : `build worker exited with code ${exitCode} before reporting a result`;
66
72
  this.#logger.error(`[build-batch] generation=${request.generation} ${message}`);
67
- return {
68
- generation: request.generation,
69
- errors: Object.fromEntries(request.needs.map((need) => [need, message])),
70
- crashed: true,
71
- };
73
+ //? A need that reported before the crash keeps what it reported: a worker killed during pages built ssr and css.
74
+ const crashedNeeds = request.needs.filter((need) => !reported.has(need));
75
+ const failed = request.needs.flatMap((need) => {
76
+ if (!reported.has(need)) return [[need, message] as const];
77
+ const error = reported.get(need);
78
+ return error ? [[need, error] as const] : [];
79
+ });
80
+ return { generation: request.generation, errors: Object.fromEntries(failed), crashed: true, crashedNeeds };
72
81
  }
73
82
  }
@@ -0,0 +1,94 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { type BatchJob, BuilderWorkQueue } from "./builderWorkQueue";
3
+
4
+ const gate = () => {
5
+ const { promise: opened, resolve: open } = Promise.withResolvers<void>();
6
+ return { open, opened };
7
+ };
8
+
9
+ describe("BuilderWorkQueue", () => {
10
+ test("runs jobs one at a time, in the order they were queued", async () => {
11
+ const order: string[] = [];
12
+ const queue = new BuilderWorkQueue({ runBatch: async () => undefined });
13
+ const first = gate();
14
+ const a = queue.enqueue("a", async () => {
15
+ await first.opened;
16
+ order.push("a");
17
+ });
18
+ const b = queue.enqueue("b", async () => {
19
+ order.push("b");
20
+ return 2;
21
+ });
22
+ expect(queue.size).toBe(2);
23
+ first.open();
24
+ await a;
25
+ expect(await b).toBe(2);
26
+ expect(order).toEqual(["a", "b"]);
27
+ expect(queue.size).toBe(0);
28
+ });
29
+
30
+ test("folds a batch queued behind another batch into it, for the newest generation", async () => {
31
+ const ran: BatchJob[] = [];
32
+ const running = gate();
33
+ const queue = new BuilderWorkQueue({
34
+ runBatch: async (batch) => {
35
+ ran.push(batch);
36
+ if (ran.length === 1) await running.opened;
37
+ },
38
+ });
39
+ const first = queue.enqueueBatch({ generation: 1, needs: ["pages", "css"], changedFiles: ["a.tsx"] });
40
+ const second = queue.enqueueBatch({
41
+ generation: 2,
42
+ needs: ["pages", "css"],
43
+ changedFiles: ["b.tsx"],
44
+ discovery: { files: ["b.tsx"], refresh: false, generation: 2 },
45
+ });
46
+ const third = queue.enqueueBatch({ generation: 3, needs: ["csr", "pages"], changedFiles: ["a.tsx", "c.tsx"] });
47
+ expect(second).toBe(third);
48
+ running.open();
49
+ await Promise.all([first, second, third]);
50
+ expect(ran).toHaveLength(2);
51
+ expect(ran[1]).toEqual({
52
+ generation: 3,
53
+ needs: ["pages", "css", "csr"],
54
+ changedFiles: ["b.tsx", "a.tsx", "c.tsx"],
55
+ discovery: { files: ["b.tsx"], refresh: false, generation: 2 },
56
+ });
57
+ });
58
+
59
+ test("keeps a batch queued after a task behind that task", async () => {
60
+ const order: string[] = [];
61
+ const running = gate();
62
+ const queue = new BuilderWorkQueue({
63
+ runBatch: async (batch) => {
64
+ order.push(`batch:${batch.generation}`);
65
+ if (batch.generation === 1) await running.opened;
66
+ },
67
+ });
68
+ const first = queue.enqueueBatch({ generation: 1, needs: ["pages"], changedFiles: [] });
69
+ const route = queue.enqueue("route", async () => {
70
+ order.push("route");
71
+ });
72
+ const second = queue.enqueueBatch({ generation: 2, needs: ["pages"], changedFiles: [] });
73
+ running.open();
74
+ await Promise.all([first, route, second]);
75
+ expect(order).toEqual(["batch:1", "route", "batch:2"]);
76
+ });
77
+
78
+ test("drains jobs queued while it waits, and survives a job that throws", async () => {
79
+ const queue = new BuilderWorkQueue({ runBatch: async () => undefined });
80
+ const failing = queue.enqueue("fails", async () => {
81
+ throw new Error("boom");
82
+ });
83
+ let late = false;
84
+ void queue.enqueue("queues-more", async () => {
85
+ void queue.enqueue("late", async () => {
86
+ late = true;
87
+ });
88
+ });
89
+ await expect(failing).rejects.toThrow("boom");
90
+ await queue.drain();
91
+ expect(late).toBe(true);
92
+ expect(queue.size).toBe(0);
93
+ });
94
+ });
@@ -0,0 +1,126 @@
1
+ import type { HmrTrace } from "akanjs/server";
2
+ import type { BuildBatchNeed } from "./buildBatchProtocol";
3
+
4
+ export interface DiscoveryJob {
5
+ files: string[];
6
+ refresh: boolean;
7
+ /** The newest batch these files came from: a route build that starts after has taken it in. */
8
+ generation: number;
9
+ }
10
+
11
+ export interface BatchJob {
12
+ generation: number;
13
+ needs: BuildBatchNeed[];
14
+ changedFiles: string[];
15
+ trace?: HmrTrace;
16
+ /** Code files whose client-entry discovery goes stale before this batch builds; `refresh` rebuilds it whole. */
17
+ discovery?: DiscoveryJob;
18
+ }
19
+
20
+ interface QueuedJob {
21
+ label: string;
22
+ batch: BatchJob | null;
23
+ run: () => Promise<void>;
24
+ settle: PromiseWithResolvers<void>;
25
+ }
26
+
27
+ export interface BuilderWorkQueueOptions {
28
+ runBatch: (batch: BatchJob) => Promise<void>;
29
+ onSettled?: (label: string, ms: number) => void;
30
+ }
31
+
32
+ //* The builder's slow lane: build-worker batches, route builds and full CSR builds run here one at a time, so a save's
33
+ //* CSR patch never waits behind them. A batch queued behind another batch folds into it: a burst of saves builds pages
34
+ //* and css once for the newest generation instead of once per save.
35
+ export class BuilderWorkQueue {
36
+ readonly #runBatch: BuilderWorkQueueOptions["runBatch"];
37
+ readonly #onSettled: BuilderWorkQueueOptions["onSettled"];
38
+ readonly #pending: QueuedJob[] = [];
39
+ #running: QueuedJob | null = null;
40
+ #idle: Promise<void> = Promise.resolve();
41
+ #wakeIdle: (() => void) | null = null;
42
+
43
+ constructor({ runBatch, onSettled }: BuilderWorkQueueOptions) {
44
+ this.#runBatch = runBatch;
45
+ this.#onSettled = onSettled;
46
+ }
47
+
48
+ /** Jobs waiting or running. */
49
+ get size(): number {
50
+ return this.#pending.length + (this.#running ? 1 : 0);
51
+ }
52
+
53
+ enqueueBatch(batch: BatchJob): Promise<void> {
54
+ const tail = this.#pending.at(-1);
55
+ if (tail?.batch) {
56
+ tail.batch = BuilderWorkQueue.merge(tail.batch, batch);
57
+ return tail.settle.promise;
58
+ }
59
+ const job = this.#job("batch", batch, async () => undefined);
60
+ job.run = async () => await this.#runBatch(job.batch ?? batch);
61
+ return this.#push(job);
62
+ }
63
+
64
+ enqueue<T>(label: string, run: () => Promise<T>): Promise<T> {
65
+ let result: T | undefined;
66
+ const job = this.#job(label, null, async () => {
67
+ result = await run();
68
+ });
69
+ return this.#push(job).then(() => result as T);
70
+ }
71
+
72
+ /** Resolves once nothing is waiting or running, including jobs queued while it waits. */
73
+ async drain(): Promise<void> {
74
+ while (this.size > 0) await this.#idle;
75
+ }
76
+
77
+ static merge(into: BatchJob, next: BatchJob): BatchJob {
78
+ const discovery =
79
+ into.discovery || next.discovery
80
+ ? {
81
+ files: [...new Set([...(into.discovery?.files ?? []), ...(next.discovery?.files ?? [])])],
82
+ refresh: !!into.discovery?.refresh || !!next.discovery?.refresh,
83
+ generation: Math.max(into.discovery?.generation ?? 0, next.discovery?.generation ?? 0),
84
+ }
85
+ : undefined;
86
+ return {
87
+ generation: Math.max(into.generation, next.generation),
88
+ needs: [...new Set([...into.needs, ...next.needs])],
89
+ changedFiles: [...new Set([...into.changedFiles, ...next.changedFiles])],
90
+ trace: next.trace ?? into.trace,
91
+ ...(discovery ? { discovery } : {}),
92
+ };
93
+ }
94
+
95
+ #job(label: string, batch: BatchJob | null, run: () => Promise<void>): QueuedJob {
96
+ return { label, batch, run, settle: Promise.withResolvers<void>() };
97
+ }
98
+
99
+ #push(job: QueuedJob): Promise<void> {
100
+ if (this.size === 0)
101
+ this.#idle = new Promise<void>((resolve) => {
102
+ this.#wakeIdle = resolve;
103
+ });
104
+ this.#pending.push(job);
105
+ if (!this.#running) void this.#pump();
106
+ return job.settle.promise;
107
+ }
108
+
109
+ async #pump(): Promise<void> {
110
+ for (let job = this.#pending.shift(); job; job = this.#pending.shift()) {
111
+ this.#running = job;
112
+ const started = Date.now();
113
+ try {
114
+ await job.run();
115
+ job.settle.resolve();
116
+ } catch (error) {
117
+ job.settle.reject(error);
118
+ } finally {
119
+ this.#running = null;
120
+ this.#onSettled?.(job.label, Date.now() - started);
121
+ }
122
+ }
123
+ this.#wakeIdle?.();
124
+ this.#wakeIdle = null;
125
+ }
126
+ }
@@ -1,4 +1,8 @@
1
1
  import { afterEach, describe, expect, mock, 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 { CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
2
6
  import { MemoryLimit } from "akanjs/server/memoryLimit";
3
7
  import { IncrementalBuilderHost } from "./incrementalBuilder.host";
4
8
 
@@ -172,6 +176,36 @@ describe("IncrementalBuilderHost", () => {
172
176
  host.stop();
173
177
  expect(host.recycle("after stop")).toBe(false);
174
178
  });
179
+
180
+ test("a patcher a crash turned off stays off in the host that replaces this one", async () => {
181
+ const cwdPath = await mkdtemp(path.join(os.tmpdir(), "akan-builder-patcher-"));
182
+ try {
183
+ const spawns = mockSpawns();
184
+ const app = { cwdPath } as never;
185
+ const host = new IncrementalBuilderHost({ app, entry: "/tmp/builder.ts", env: {}, onMessage: () => undefined });
186
+ host.start();
187
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
188
+ const marker = path.join(cwdPath, ".akan/artifact", SSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER);
189
+ await Bun.write(marker, "");
190
+ spawns[0]?.options.onExit?.();
191
+ await wait(1_050);
192
+ expect(spawns[1]?.options.env?.AKAN_DEV_CSR_PATCHER).toBe("off");
193
+ expect(host.patcherOff).toBe(true);
194
+ host.stop();
195
+ const next = new IncrementalBuilderHost({
196
+ app,
197
+ entry: "/tmp/builder.ts",
198
+ env: {},
199
+ onMessage: () => undefined,
200
+ patcherOff: host.patcherOff,
201
+ });
202
+ next.start();
203
+ expect(spawns[2]?.options.env?.AKAN_DEV_CSR_PATCHER).toBe("off");
204
+ next.stop();
205
+ } finally {
206
+ await rm(cwdPath, { recursive: true, force: true });
207
+ }
208
+ });
175
209
  });
176
210
 
177
211
  describe("IncrementalBuilderHost.maxRssBytes", () => {
@@ -196,7 +230,7 @@ describe("IncrementalBuilderHost.maxRssBytes", () => {
196
230
  withEnv(
197
231
  { AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: undefined },
198
232
  () => {
199
- expect(IncrementalBuilderHost.maxRssBytes()).toBe(1_200 * 1024 * 1024);
233
+ expect(IncrementalBuilderHost.maxRssBytes()).toBe(2_048 * 1024 * 1024);
200
234
  },
201
235
  );
202
236
  });
@@ -1,6 +1,8 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import { Logger } from "akanjs/common";
3
4
  import type { BuilderMessage } from "akanjs/server";
5
+ import { CSR_DEV_DIRNAME, CSR_DEV_PATCHING_MARKER, SSR_DEV_DIRNAME } from "akanjs/server/hmr/csrDevManifest";
4
6
  import { MemoryLimit } from "akanjs/server/memoryLimit";
5
7
  import type { App } from "../commandDecorators";
6
8
 
@@ -12,8 +14,10 @@ const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
12
14
  "css-updated",
13
15
  "pages-updated",
14
16
  "csr-updated",
17
+ "ssr-updated",
15
18
  "build-status",
16
19
  "builder-metrics",
20
+ "boot-armed",
17
21
  ]);
18
22
  /** Prefer `"pipe"` under a TUI: a Bun child that inherits the terminal restores its spawn-time termios on exit. */
19
23
  export type DevStdioMode = "inherit" | "pipe";
@@ -26,6 +30,8 @@ interface IncrementalBuilderHostOptions {
26
30
  onMessage: (message: BuilderMessage) => void;
27
31
  /** Required with `stdio: "pipe"` (an undrained pipe blocks the builder); receives decoded chunks, not lines. */
28
32
  onOutput?: (kind: "stdout" | "stderr", text: string) => void;
33
+ /** Carried over from the host this one replaces: a patcher a crash turned off stays off. */
34
+ patcherOff?: boolean;
29
35
  }
30
36
 
31
37
  /** `recycling`: the builder finishes accepted work but refuses new requests; hold them for the replacement. */
@@ -45,8 +51,9 @@ export class IncrementalBuilderHost {
45
51
  static readonly #restartBaseDelayMs = 1_000;
46
52
  static readonly #restartMaxDelayMs = 30_000;
47
53
  static readonly #recycleDrainTimeoutMs = 30_000;
48
- // A fresh boot is ~300-600MB; this leaves room for one full rebuild on top.
49
- static readonly #devMaxRssBytes = 1_200 * 1024 * 1024;
54
+ // apps/akan's builder peaks near 950MB holding the SSR registry's patcher through a route build, and arming CSR adds
55
+ // a second patcher: this recycles only a builder grown well past both.
56
+ static readonly #devMaxRssBytes = 2_048 * 1024 * 1024;
50
57
  logger = new Logger("IncrementalBuilderHost");
51
58
  entry: string;
52
59
  env: Record<string, string>;
@@ -62,22 +69,35 @@ export class IncrementalBuilderHost {
62
69
  #recycleTimer: ReturnType<typeof setTimeout> | null = null;
63
70
  #recycleRequested: boolean = false;
64
71
  #spawnAfterRecycle: boolean = false;
72
+ #patcherOff: boolean = false;
65
73
  #manualStop = false;
66
74
  // Nothing else answers a request whose builder exits holding it: a crash or kill sends nothing, and a drain races
67
75
  // its own exit, so an unanswered page request would spin forever.
68
76
  readonly #inFlight = new Map<number, "build-route" | "build-csr">();
69
77
  #startOptions: IncrementalBuilderStartOptions = {};
70
- constructor({ app, entry, env, stdio = "inherit", onMessage, onOutput }: IncrementalBuilderHostOptions) {
78
+ constructor({
79
+ app,
80
+ entry,
81
+ env,
82
+ stdio = "inherit",
83
+ onMessage,
84
+ onOutput,
85
+ patcherOff = false,
86
+ }: IncrementalBuilderHostOptions) {
71
87
  this.app = app;
72
88
  this.entry = entry;
73
89
  this.env = env;
74
90
  this.#stdio = stdio;
75
91
  this.#onMessage = onMessage;
76
92
  this.#onOutput = onOutput ?? null;
93
+ this.#patcherOff = patcherOff;
77
94
  }
78
95
  get status() {
79
96
  return this.#status;
80
97
  }
98
+ get patcherOff(): boolean {
99
+ return this.#patcherOff;
100
+ }
81
101
  /** For reading RSS between builds: the builder's own metrics sample the post-work peak, stale once arenas return. */
82
102
  get pid(): number | null {
83
103
  return this.#proc?.pid ?? null;
@@ -95,10 +115,16 @@ export class IncrementalBuilderHost {
95
115
  this.ready = false;
96
116
  const afterRecycle = this.#spawnAfterRecycle;
97
117
  this.#spawnAfterRecycle = false;
118
+ this.#checkPatchingMarker(isRestart && !afterRecycle);
98
119
  let proc!: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe">;
99
120
  proc = Bun.spawn(["bun", this.entry], {
100
121
  cwd: this.app.cwdPath,
101
- env: { ...this.env, AKAN_WATCH: "1", ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}) },
122
+ env: {
123
+ ...this.env,
124
+ AKAN_WATCH: "1",
125
+ ...(afterRecycle ? { AKAN_BUILDER_ANNOUNCE_BOOT: "1" } : {}),
126
+ ...(this.#patcherOff ? { AKAN_DEV_CSR_PATCHER: "off" } : {}),
127
+ },
102
128
  stdio: ["ignore", this.#stdio, this.#stdio],
103
129
  ipc: (msg: BuilderMessage) => {
104
130
  if (this.#proc !== proc) return;
@@ -152,6 +178,23 @@ export class IncrementalBuilderHost {
152
178
  }
153
179
  this.logger.verbose(`builder spawned pid=${proc.pid} entry=${this.entry}${isRestart ? " restart=1" : ""}`);
154
180
  }
181
+ //? The resident builder patches the dev CSR bundle in its own process, so a bundler crash there takes the file watcher
182
+ //? with it. A builder that died holding the marker hands every later CSR save to build workers for this session;
183
+ //? a marker any other exit left behind (Ctrl-C mid-patch) is cleared so it cannot turn the patcher off next session.
184
+ #checkPatchingMarker(afterCrash: boolean): void {
185
+ const markers = [CSR_DEV_DIRNAME, SSR_DEV_DIRNAME]
186
+ .map((dirName) => path.join(this.app.cwdPath, ".akan/artifact", dirName, CSR_DEV_PATCHING_MARKER))
187
+ .filter((marker) => fs.existsSync(marker));
188
+ if (markers.length === 0) return;
189
+ if (afterCrash && !this.#patcherOff) {
190
+ this.#patcherOff = true;
191
+ this.logger.warn(
192
+ "the builder died while patching a dev module registry; registry saves go to a build worker until the next config or metadata restart (AKAN_DEV_CSR_PATCHER=off)",
193
+ );
194
+ }
195
+ for (const marker of markers) fs.rmSync(marker, { force: true });
196
+ }
197
+
155
198
  async #drain(stream: ReadableStream<Uint8Array> | undefined | null, kind: "stdout" | "stderr") {
156
199
  if (!stream) return;
157
200
  const decoder = new TextDecoder();
@@ -263,7 +306,8 @@ export class IncrementalBuilderHost {
263
306
  {
264
307
  stdio = "inherit",
265
308
  onOutput,
266
- }: { stdio?: DevStdioMode; onOutput?: (kind: "stdout" | "stderr", text: string) => void } = {},
309
+ patcherOff = false,
310
+ }: Pick<IncrementalBuilderHostOptions, "stdio" | "onOutput" | "patcherOff"> = {},
267
311
  ) {
268
312
  const candidates = [
269
313
  path.join(app.workspace.workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/incrementalBuilder.proc.ts"),
@@ -276,7 +320,7 @@ export class IncrementalBuilderHost {
276
320
  ];
277
321
  for (const c of candidates)
278
322
  if (await Bun.file(c).exists())
279
- return new IncrementalBuilderHost({ app, entry: c, env, stdio, onMessage, onOutput });
323
+ return new IncrementalBuilderHost({ app, entry: c, env, stdio, onMessage, onOutput, patcherOff });
280
324
  throw new Error(`[cli] frontend builder entry not found; looked in: ${candidates.join(", ")}`);
281
325
  }
282
326
  }