@akanjs/devkit 3.0.0-beta.19 → 3.0.0-beta.20

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 (233) hide show
  1. package/abstractDoc.test.ts +6 -15
  2. package/abstractDoc.ts +1 -4
  3. package/agentsIndex.test.ts +4 -7
  4. package/agentsIndex.ts +6 -22
  5. package/akanApp/BackendImportGraph.test.ts +8 -22
  6. package/akanApp/BackendImportGraph.ts +1 -20
  7. package/akanApp/akanApp.host.test.ts +1 -3
  8. package/akanApp/akanApp.host.ts +64 -250
  9. package/akanApp/devHostPolicy.test.ts +10 -34
  10. package/akanApp/devHostPolicy.ts +12 -98
  11. package/akanConfig/akanConfig.test.ts +30 -39
  12. package/akanConfig/akanConfig.ts +40 -136
  13. package/akanContext.ts +106 -159
  14. package/akanMcpContract.ts +28 -73
  15. package/appSelectionMemory.ts +1 -4
  16. package/applicationBuildReporter.ts +37 -5
  17. package/applicationBuildRunner.ts +25 -59
  18. package/applicationReleasePackager.ts +24 -44
  19. package/applicationTestPreload.ts +3 -12
  20. package/artifact/implicitRootLayout.test.ts +3 -19
  21. package/artifact/implicitRootLayout.ts +34 -91
  22. package/artifact/routeSeedIndex.ts +11 -44
  23. package/biomeBase.ts +2 -5
  24. package/capacitorApp.test.ts +4 -14
  25. package/capacitorApp.ts +86 -235
  26. package/cloud/cloudApi.test.ts +202 -0
  27. package/cloud/cloudApi.ts +33 -57
  28. package/cloud/configLock.test.ts +64 -0
  29. package/cloud/configLock.ts +96 -0
  30. package/cloud/constants.ts +3 -2
  31. package/cloud/globalConfig.ts +64 -31
  32. package/codeAgent/agent/AkanCodeServices.ts +15 -57
  33. package/codeAgent/agent/AkanContextFiles.ts +5 -28
  34. package/codeAgent/agent/CodeAgent.ts +33 -108
  35. package/codeAgent/agent/CodeAgentAsks.ts +7 -30
  36. package/codeAgent/agent/CodeAgentEventMapper.ts +9 -52
  37. package/codeAgent/agent/CodeAgentGate.ts +8 -34
  38. package/codeAgent/agent/CodeAgentProxy.ts +4 -13
  39. package/codeAgent/agent/CodeAgentRpcHost.ts +3 -16
  40. package/codeAgent/agent/CodeAgentRpcListener.ts +2 -10
  41. package/codeAgent/agent/CodeAgentStreamPrinter.ts +4 -15
  42. package/codeAgent/agent/CodeAgentSuspended.ts +1 -8
  43. package/codeAgent/agent/CodeAgentUi.ts +2 -7
  44. package/codeAgent/agent/CodeMailbox.ts +7 -34
  45. package/codeAgent/agent/CodeSessionFork.ts +4 -19
  46. package/codeAgent/agent/CodeSessionIndex.ts +5 -31
  47. package/codeAgent/agent/SubagentPool.ts +48 -58
  48. package/codeAgent/agent/akanCodeModel.ts +5 -39
  49. package/codeAgent/agent/akanCodePaths.ts +4 -24
  50. package/codeAgent/agent/akanContextFiles.test.ts +0 -2
  51. package/codeAgent/agent/akanSystemPrompt.ts +1 -9
  52. package/codeAgent/agent/codeAgent.test.ts +178 -0
  53. package/codeAgent/agent/codeAgentContract.test.ts +5 -28
  54. package/codeAgent/agent/codeMailbox.test.ts +0 -4
  55. package/codeAgent/agent/codeSessionFork.test.ts +0 -2
  56. package/codeAgent/agent/codeSessionIndex.test.ts +9 -4
  57. package/codeAgent/agent/consoleToStderr.ts +2 -12
  58. package/codeAgent/agent/jsonLines.ts +29 -0
  59. package/codeAgent/agent/stringArg.ts +5 -0
  60. package/codeAgent/feedback/DevLogFeedback.ts +3 -18
  61. package/codeAgent/feedback/PreviewChrome.ts +5 -29
  62. package/codeAgent/feedback/PreviewFeedback.ts +2 -9
  63. package/codeAgent/feedback/PreviewView.ts +3 -15
  64. package/codeAgent/feedback/TurnFeedback.ts +2 -17
  65. package/codeAgent/feedback/VerifyFeedback.ts +1 -7
  66. package/codeAgent/feedback/previewChrome.test.ts +1 -1
  67. package/codeAgent/index.ts +2 -10
  68. package/codeAgent/skills/akan-code/SKILL.md +12 -4
  69. package/codeAgent/tools/AkanCli.ts +4 -16
  70. package/codeAgent/tools/AkanCodePlugins.ts +21 -43
  71. package/codeAgent/tools/AkanEditScope.ts +2 -12
  72. package/codeAgent/tools/AkanEnvKeys.ts +3 -11
  73. package/codeAgent/tools/AkanToolPack.ts +6 -28
  74. package/codeAgent/tools/AkanVerifier.ts +1 -8
  75. package/codeAgent/tools/AskToolPack.ts +3 -15
  76. package/codeAgent/tools/MailToolPack.ts +0 -9
  77. package/codeAgent/tools/McpClient.ts +3 -24
  78. package/codeAgent/tools/McpOAuth.ts +6 -41
  79. package/codeAgent/tools/McpOAuthLoopback.ts +2 -15
  80. package/codeAgent/tools/McpServerConfig.ts +7 -26
  81. package/codeAgent/tools/McpSignIn.ts +46 -49
  82. package/codeAgent/tools/McpTokenStore.ts +6 -17
  83. package/codeAgent/tools/McpToolPack.ts +107 -35
  84. package/codeAgent/tools/SessionToolPack.ts +1 -11
  85. package/codeAgent/tools/WebToolPack.ts +1 -7
  86. package/codeAgent/tools/akanCodePlugins.test.ts +231 -63
  87. package/codeAgent/tools/askToolPack.test.ts +0 -3
  88. package/codeAgent/tools/mcpOAuth.test.ts +315 -27
  89. package/codeAgent/tools/mcpServerConfig.test.ts +21 -14
  90. package/codegenLock.test.ts +4 -13
  91. package/codegenLock.ts +3 -17
  92. package/commandDecorators/argMeta.ts +1 -5
  93. package/commandDecorators/camelToKebabCase.ts +1 -0
  94. package/commandDecorators/command.ts +44 -102
  95. package/commandDecorators/commandBuilder.ts +30 -61
  96. package/commandDecorators/commandDecorators.test.ts +1 -9
  97. package/commandDecorators/dependencyBuilder.ts +10 -1
  98. package/commandDecorators/helpFormatter.ts +66 -144
  99. package/commandDecorators/index.ts +0 -2
  100. package/commandDecorators/targetMeta.ts +9 -7
  101. package/createTunnel.ts +7 -14
  102. package/dependencyScanner.test.ts +4 -16
  103. package/dependencyScanner.ts +27 -78
  104. package/devkitUtils.test.ts +47 -19
  105. package/executors.test.ts +114 -60
  106. package/executors.ts +165 -484
  107. package/fileEditor.ts +9 -37
  108. package/{getDirname.test.ts → fileSys.test.ts} +1 -1
  109. package/fileSys.ts +6 -8
  110. package/formSetterScanner.ts +3 -16
  111. package/frontendBuild/allRoutesBuilder.ts +7 -21
  112. package/frontendBuild/autoImportSync.test.ts +14 -38
  113. package/frontendBuild/autoImportSync.ts +15 -67
  114. package/frontendBuild/buildRouteClient.test.ts +10 -36
  115. package/frontendBuild/bundleDefine.ts +12 -0
  116. package/frontendBuild/clientBuildTypes.ts +5 -36
  117. package/frontendBuild/clientEntriesBundler.ts +9 -40
  118. package/frontendBuild/clientEntryDiscovery.ts +6 -41
  119. package/frontendBuild/csrArtifactBuilder.ts +6 -25
  120. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  121. package/frontendBuild/cssCandidateCache.ts +6 -41
  122. package/frontendBuild/cssCompiler.ts +29 -78
  123. package/frontendBuild/cssImportResolver.ts +5 -18
  124. package/frontendBuild/devChangePlanner.ts +7 -10
  125. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  126. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  127. package/frontendBuild/fontOptimizer.test.ts +5 -13
  128. package/frontendBuild/fontOptimizer.ts +15 -72
  129. package/frontendBuild/fontPruner.test.ts +5 -10
  130. package/frontendBuild/fontPruner.ts +5 -20
  131. package/frontendBuild/frontendBuild.test.ts +27 -52
  132. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  133. package/frontendBuild/hmrWatcher.test.ts +7 -31
  134. package/frontendBuild/hmrWatcher.ts +14 -71
  135. package/frontendBuild/pagesBundleBuilder.ts +4 -29
  136. package/frontendBuild/precompressArtifacts.ts +5 -18
  137. package/frontendBuild/routeClientBuilder.ts +7 -47
  138. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  139. package/frontendBuild/sourceMtimeIndex.test.ts +37 -94
  140. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  141. package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -8
  142. package/frontendBuild/styleContract.ts +1 -8
  143. package/frontendBuild/themeValidator.ts +4 -26
  144. package/frontendBuild/watchRootResolver.test.ts +23 -42
  145. package/frontendBuild/watchRootResolver.ts +5 -27
  146. package/getRelatedCnsts.ts +12 -92
  147. package/incrementalBuilder/buildBatch.proc.ts +22 -57
  148. package/incrementalBuilder/buildBatchProtocol.ts +8 -36
  149. package/incrementalBuilder/buildBatchRunner.test.ts +34 -0
  150. package/incrementalBuilder/buildBatchRunner.ts +10 -31
  151. package/incrementalBuilder/builderChannel.test.ts +24 -49
  152. package/incrementalBuilder/builderChannel.ts +6 -37
  153. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  154. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  155. package/incrementalBuilder/devWatchBatch.test.ts +14 -18
  156. package/incrementalBuilder/incrementalBuilder.host.test.ts +18 -55
  157. package/incrementalBuilder/incrementalBuilder.host.ts +18 -76
  158. package/incrementalBuilder/incrementalBuilder.proc.test.ts +81 -0
  159. package/incrementalBuilder/incrementalBuilder.proc.ts +48 -161
  160. package/index.ts +3 -15
  161. package/integration/devResourceProbe.ts +50 -56
  162. package/integration/devStability.integration.test.ts +47 -194
  163. package/integration/devStabilityHarness.test.ts +9 -29
  164. package/integration/devStabilityHarness.ts +65 -330
  165. package/integration/ssrMemoryProbe.ts +41 -167
  166. package/libSource.test.ts +4 -15
  167. package/libSource.ts +19 -31
  168. package/lint/gritRules.test.ts +6 -23
  169. package/linter.test.ts +1 -2
  170. package/linter.ts +22 -115
  171. package/mobile/mobileTarget.ts +14 -13
  172. package/package.json +2 -2
  173. package/packageExports.test.ts +3 -18
  174. package/packageExportsMap.ts +3 -19
  175. package/piDependencyPins.test.ts +3 -14
  176. package/platformTest/IdleWatchedProcess.test.ts +5 -13
  177. package/platformTest/IdleWatchedProcess.ts +1 -5
  178. package/platformTest/LinuxDockerTarget.ts +20 -35
  179. package/platformTest/PlatformTestRun.test.ts +5 -9
  180. package/platformTest/PlatformTestTarget.ts +24 -2
  181. package/platformTest/TestSnapshot.test.ts +6 -14
  182. package/platformTest/TestSnapshot.ts +1 -5
  183. package/platformTest/WindowsSshTarget.ts +6 -22
  184. package/prompter.ts +3 -6
  185. package/qualityScanner.test.ts +6 -24
  186. package/qualityScanner.ts +196 -284
  187. package/recipeScanner.test.ts +7 -12
  188. package/recipeScanner.ts +9 -38
  189. package/repoIdentity.ts +2 -9
  190. package/routeSourceValidator.ts +7 -30
  191. package/scanInfo.ts +82 -214
  192. package/selectModel.ts +1 -2
  193. package/semver.ts +3 -5
  194. package/slicePlanner.test.ts +6 -24
  195. package/slicePlanner.ts +11 -32
  196. package/spinner.test.ts +3 -8
  197. package/spinner.ts +4 -16
  198. package/sshTunnel.ts +1 -6
  199. package/ssrScanner.test.ts +1 -6
  200. package/ssrScanner.ts +103 -113
  201. package/subspace.test.ts +6 -25
  202. package/subspace.ts +31 -109
  203. package/subspaceConfig.ts +3 -11
  204. package/testHelpers.ts +49 -34
  205. package/transforms/asyncDefaultExportDetector.ts +3 -11
  206. package/transforms/barrelAnalyzer.ts +43 -135
  207. package/transforms/barrelImportsPlugin.ts +38 -175
  208. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  209. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  210. package/transforms/moduleSyntax.ts +32 -0
  211. package/transforms/rscUseClientTransform.ts +35 -20
  212. package/transforms/transforms.test.ts +153 -44
  213. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  214. package/transforms/useClientBundlePlugin.ts +2 -22
  215. package/typeChecker.ts +7 -63
  216. package/uploadRelease.ts +10 -25
  217. package/workflow/artifacts.ts +18 -0
  218. package/workflow/executor.test.ts +1 -12
  219. package/workflow/executor.ts +85 -146
  220. package/workflow/index.ts +0 -1
  221. package/workflow/moduleIndex.test.ts +4 -15
  222. package/workflow/moduleIndex.ts +9 -49
  223. package/workflow/plan.ts +18 -25
  224. package/workflow/render.ts +80 -96
  225. package/workflow/source.ts +33 -56
  226. package/workflow/sourceAst.ts +50 -0
  227. package/workspaceLayout.ts +4 -19
  228. package/commandDecorators/commandMeta.ts +0 -7
  229. package/commandDecorators/types.ts +0 -10
  230. package/getDirname.ts +0 -4
  231. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  232. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  233. package/workflow/primitive.ts +0 -59
@@ -26,18 +26,25 @@ const mockSpawns = (): SpawnRecord[] => {
26
26
  return spawns;
27
27
  };
28
28
 
29
+ const createHost = (onMessage: (message: unknown) => void = () => undefined) =>
30
+ new IncrementalBuilderHost({ app: { cwdPath: "/tmp/app" } as never, entry: "/tmp/builder.ts", env: {}, onMessage });
31
+
32
+ const startRecordingHost = () => {
33
+ const spawns = mockSpawns();
34
+ const messages: unknown[] = [];
35
+ const host = createHost((message) => messages.push(message));
36
+ host.start();
37
+ spawns[0]?.options.ipc?.({ type: "builder-ready" });
38
+ return { spawns, messages, host };
39
+ };
40
+
29
41
  describe("IncrementalBuilderHost", () => {
30
42
  test("restarts after a ready builder exits", async () => {
31
43
  const spawns = mockSpawns();
32
44
 
33
45
  const onReady = mock();
34
46
  const onRestartReady = mock();
35
- const host = new IncrementalBuilderHost({
36
- app: { cwdPath: "/tmp/app" } as never,
37
- entry: "/tmp/builder.ts",
38
- env: {},
39
- onMessage: () => undefined,
40
- });
47
+ const host = createHost();
41
48
 
42
49
  host.start({ onReady, onRestartReady });
43
50
  spawns[0]?.options.ipc?.({ type: "builder-ready" });
@@ -60,12 +67,7 @@ describe("IncrementalBuilderHost", () => {
60
67
  test("recycles a ready builder gracefully and replaces it immediately", async () => {
61
68
  const spawns = mockSpawns();
62
69
  const onRestartReady = mock();
63
- const host = new IncrementalBuilderHost({
64
- app: { cwdPath: "/tmp/app" } as never,
65
- entry: "/tmp/builder.ts",
66
- env: {},
67
- onMessage: () => undefined,
68
- });
70
+ const host = createHost();
69
71
 
70
72
  host.start({ onRestartReady });
71
73
  spawns[0]?.options.ipc?.({ type: "builder-ready" });
@@ -73,18 +75,14 @@ describe("IncrementalBuilderHost", () => {
73
75
 
74
76
  const reason = "rss=1300MiB>=1200MiB after 3 build(s)";
75
77
  expect(host.recycle(reason)).toBe(true);
76
- // Graceful: the builder is asked to drain, not killed, so a rebuild in flight still completes.
77
78
  expect(spawns[0]?.proc.send).toHaveBeenCalledWith({ type: "builder-shutdown", reason });
78
79
  expect(spawns[0]?.proc.kill).not.toHaveBeenCalled();
79
80
  expect(host.recycle("second request")).toBe(false);
80
81
 
81
- // The drain refuses everything that arrives during it, so a request sent here is a request the
82
- // developer gets an error page for. Reporting the state is what lets the host hold it instead.
83
82
  expect(host.status).toBe("recycling");
84
83
  expect(host.send({ type: "build-route", id: 9, routeId: "z", seeds: [], knownEntries: [] })).toBe(false);
85
84
  expect(spawns[0]?.proc.send).toHaveBeenCalledTimes(1);
86
85
 
87
- // A planned exit skips the crash backoff — the dev server has no file watcher until it is back.
88
86
  spawns[0]?.options.onExit?.();
89
87
  expect(spawns).toHaveLength(2);
90
88
  expect(spawns[1]?.options.env?.AKAN_BUILDER_ANNOUNCE_BOOT).toBe("1");
@@ -103,17 +101,7 @@ describe("IncrementalBuilderHost", () => {
103
101
  });
104
102
 
105
103
  test("fails the requests a departing builder never answered", async () => {
106
- const spawns = mockSpawns();
107
- const messages: unknown[] = [];
108
- const host = new IncrementalBuilderHost({
109
- app: { cwdPath: "/tmp/app" } as never,
110
- entry: "/tmp/builder.ts",
111
- env: {},
112
- onMessage: (message) => messages.push(message),
113
- });
114
-
115
- host.start();
116
- spawns[0]?.options.ipc?.({ type: "builder-ready" });
104
+ const { spawns, messages, host } = startRecordingHost();
117
105
  expect(host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] })).toBe(true);
118
106
  expect(host.send({ type: "build-csr", id: 2, reason: "device webview" })).toBe(true);
119
107
  // Answered before the exit, so this one must not be failed again afterwards.
@@ -124,8 +112,6 @@ describe("IncrementalBuilderHost", () => {
124
112
  host.recycle("rss=1300MiB>=1200MiB after 3 build(s)");
125
113
  spawns[0]?.options.onExit?.();
126
114
 
127
- // Nothing else answers these: the builder only refuses requests that arrive after it starts shutting
128
- // down, and a kill or a truncated write sends nothing at all.
129
115
  expect(messages).toEqual([
130
116
  {
131
117
  type: "build-route-res",
@@ -152,17 +138,7 @@ describe("IncrementalBuilderHost", () => {
152
138
  });
153
139
 
154
140
  test("names a crash rather than a recycle, and answers on stop too", async () => {
155
- const spawns = mockSpawns();
156
- const messages: unknown[] = [];
157
- const host = new IncrementalBuilderHost({
158
- app: { cwdPath: "/tmp/app" } as never,
159
- entry: "/tmp/builder.ts",
160
- env: {},
161
- onMessage: (message) => messages.push(message),
162
- });
163
-
164
- host.start();
165
- spawns[0]?.options.ipc?.({ type: "builder-ready" });
141
+ const { spawns, messages, host } = startRecordingHost();
166
142
  host.send({ type: "build-route", id: 1, routeId: "a", seeds: [], knownEntries: [] });
167
143
  messages.length = 0;
168
144
  spawns[0]?.options.onExit?.();
@@ -175,8 +151,6 @@ describe("IncrementalBuilderHost", () => {
175
151
  },
176
152
  ]);
177
153
 
178
- // `stop()` clears the process before its exit callback runs, so the callback bails on its identity
179
- // check and cannot be the only place this happens.
180
154
  await wait(1_050);
181
155
  spawns[1]?.options.ipc?.({ type: "builder-ready" });
182
156
  messages.length = 0;
@@ -189,12 +163,7 @@ describe("IncrementalBuilderHost", () => {
189
163
 
190
164
  test("only recycles a builder that is ready", () => {
191
165
  const spawns = mockSpawns();
192
- const host = new IncrementalBuilderHost({
193
- app: { cwdPath: "/tmp/app" } as never,
194
- entry: "/tmp/builder.ts",
195
- env: {},
196
- onMessage: () => undefined,
197
- });
166
+ const host = createHost();
198
167
 
199
168
  host.start();
200
169
  expect(host.recycle("while still booting")).toBe(false);
@@ -222,11 +191,7 @@ describe("IncrementalBuilderHost.maxRssBytes", () => {
222
191
  }
223
192
  };
224
193
 
225
- /**
226
- * Only where nothing else supplies a limit, which is not true in a container: a cgroup `memory.max`
227
- * makes `resolveMaxRssBytes` derive from that instead. Measured under `docker --memory=7g`, this
228
- * returned 2.45GiB rather than the fallback — the assertion was about the runner, not the code.
229
- */
194
+ // Skipped under a cgroup `memory.max`, which `resolveMaxRssBytes` derives the ceiling from instead.
230
195
  test.skipIf(MemoryLimit.readCgroupBytes() !== null)("defaults to a dev ceiling well above a fresh boot", () => {
231
196
  withEnv(
232
197
  { AKAN_BUILDER_MAX_RSS_MB: undefined, AKAN_BUILDER_MAX_RSS: undefined, AKAN_MEMORY_LIMIT: undefined },
@@ -237,8 +202,6 @@ describe("IncrementalBuilderHost.maxRssBytes", () => {
237
202
  });
238
203
 
239
204
  test("derives the ceiling from the sandbox's own limit, wherever it runs", () => {
240
- // The property the fallback test cannot assert in a container, stated so it holds on every runner:
241
- // the builder gets 35% of whatever the sandbox is allowed.
242
205
  withEnv(
243
206
  {
244
207
  AKAN_BUILDER_MAX_RSS_MB: undefined,
@@ -14,12 +14,7 @@ const builderMsgTypeSet = new Set<BuilderMessage["type"]>([
14
14
  "build-status",
15
15
  "builder-metrics",
16
16
  ]);
17
- /**
18
- * Where a dev child process writes. `"pipe"` is not only about keeping a TUI's frame clean: a Bun child
19
- * that inherits the controlling terminal snapshots its termios at spawn and writes that snapshot back
20
- * when it exits, so a builder spawned while something holds the terminal raw turns it raw again on the
21
- * first recycle. See the XXX comment on `Spinner.oraOptions`.
22
- */
17
+ /** Prefer `"pipe"` under a TUI: a Bun child that inherits the terminal restores its spawn-time termios on exit. */
23
18
  export type DevStdioMode = "inherit" | "pipe";
24
19
 
25
20
  interface IncrementalBuilderHostOptions {
@@ -28,48 +23,28 @@ interface IncrementalBuilderHostOptions {
28
23
  env: Record<string, string>;
29
24
  stdio?: DevStdioMode;
30
25
  onMessage: (message: BuilderMessage) => void;
31
- /**
32
- * Required when `stdio` is `"pipe"`: an undrained pipe fills and blocks the builder's next write.
33
- * Text arrives as decoded chunks, not lines — a consumer that needs lines splits them itself.
34
- */
26
+ /** Required with `stdio: "pipe"` (an undrained pipe blocks the builder); receives decoded chunks, not lines. */
35
27
  onOutput?: (kind: "stdout" | "stderr", text: string) => void;
36
28
  }
37
29
 
38
- /**
39
- * `recycling` is the drain: the builder is still alive and still holds the work it accepted, but it
40
- * refuses anything new. Saying so here is what lets the host hold those requests for the replacement
41
- * instead of handing the developer the refusal.
42
- */
30
+ /** `recycling`: the builder finishes accepted work but refuses new requests; hold them for the replacement. */
43
31
  export type IncrementalBuilderStatus = "starting" | "ready" | "recycling" | "restarting" | "stopped";
44
32
 
45
33
  interface IncrementalBuilderStartOptions {
46
34
  onExit?: () => void;
47
35
  onReady?: () => void;
48
36
  onRestartReady?: () => void;
49
- /**
50
- * The builder is gone and a replacement is on its way. Distinct from `onExit`, which reports the
51
- * builder giving up: this one says the dev server is temporarily without a watcher.
52
- */
37
+ /** The builder is gone and a replacement is coming (unwatched until then); `onExit` means it gave up. */
53
38
  onAway?: () => void;
54
- /**
55
- * Ask the builder to re-announce the artifact it boots with. Needed whenever a *previous* builder's
56
- * artifact may still be live in a running backend — after an rss recycle, and after an idle wake.
57
- */
39
+ /** Re-announce the boot artifact, for when a previous builder's artifact may still be live in the backend. */
58
40
  announceBootState?: boolean;
59
41
  }
60
42
 
61
43
  export class IncrementalBuilderHost {
62
44
  static readonly #restartBaseDelayMs = 1_000;
63
45
  static readonly #restartMaxDelayMs = 30_000;
64
- /**
65
- * A builder that has stopped answering has to be replaced anyway; killing it after this long turns
66
- * a wedged drain into an ordinary restart instead of leaving the recycle stuck forever.
67
- */
68
46
  static readonly #recycleDrainTimeoutMs = 30_000;
69
- /**
70
- * Dev has no memory limit to derive a fraction from, so this is what bounds the builder there. Well
71
- * above a fresh boot (~300-600MB depending on app size) with room for one full rebuild on top.
72
- */
47
+ // A fresh boot is ~300-600MB; this leaves room for one full rebuild on top.
73
48
  static readonly #devMaxRssBytes = 1_200 * 1024 * 1024;
74
49
  logger = new Logger("IncrementalBuilderHost");
75
50
  entry: string;
@@ -87,16 +62,8 @@ export class IncrementalBuilderHost {
87
62
  #recycleRequested: boolean = false;
88
63
  #spawnAfterRecycle: boolean = false;
89
64
  #manualStop = false;
90
- /**
91
- * Requests handed to the running builder that it has not answered yet, keyed by the backend's
92
- * correlation id.
93
- *
94
- * Nothing else answers a request whose builder exits while holding it: the builder only refuses
95
- * requests that arrive *after* it starts shutting down, a kill or crash sends nothing at all, and even
96
- * a clean drain races its own `process.exit`. The builder exits routinely — it is recycled whenever its
97
- * RSS passes the ceiling — so a page request that happened to be mid route-build left the backend's
98
- * promise pending and the browser tab spinning with no error and nothing to retry.
99
- */
65
+ // Nothing else answers a request whose builder exits holding it: a crash or kill sends nothing, and a drain races
66
+ // its own exit, so an unanswered page request would spin forever.
100
67
  readonly #inFlight = new Map<number, "build-route" | "build-csr">();
101
68
  #startOptions: IncrementalBuilderStartOptions = {};
102
69
  constructor({ app, entry, env, stdio = "inherit", onMessage, onOutput }: IncrementalBuilderHostOptions) {
@@ -110,11 +77,7 @@ export class IncrementalBuilderHost {
110
77
  get status() {
111
78
  return this.#status;
112
79
  }
113
- /**
114
- * The running builder's pid, so the host can read its RSS from the OS between builds. The builder
115
- * only reports its own metrics at work-completion points, which is the *peak*; on a platform that
116
- * returns bundler arenas to the OS while idle, that sample goes stale within seconds.
117
- */
80
+ /** For reading RSS between builds: the builder's own metrics sample the post-work peak, stale once arenas return. */
118
81
  get pid(): number | null {
119
82
  return this.#proc?.pid ?? null;
120
83
  }
@@ -129,8 +92,6 @@ export class IncrementalBuilderHost {
129
92
  #spawn(isRestart: boolean) {
130
93
  this.#status = isRestart ? "restarting" : "starting";
131
94
  this.ready = false;
132
- // A fresh builder rebuilds every artifact while the running backend still holds the previous one;
133
- // the flag is what tells it to re-announce what it booted with.
134
95
  const afterRecycle = this.#spawnAfterRecycle;
135
96
  this.#spawnAfterRecycle = false;
136
97
  let proc!: Bun.Subprocess<"ignore", "inherit" | "pipe", "inherit" | "pipe">;
@@ -170,11 +131,9 @@ export class IncrementalBuilderHost {
170
131
  this.#startOptions.onExit?.();
171
132
  return;
172
133
  }
173
- // Said once for both branches below, because both leave the tree unwatched until a replacement
174
- // has primed its own index — and an edit that lands in that window is reported by nobody.
134
+ // Both branches below leave the tree unwatched until the replacement primes its index.
175
135
  this.#startOptions.onAway?.();
176
- // A recycle is a planned exit, so it neither counts as a failed attempt nor waits out the
177
- // crash backoff — the dev server is without a watcher until the replacement is up.
136
+ // A planned exit: no failed attempt, no crash backoff.
178
137
  if (wasRecycle) {
179
138
  this.logger.verbose("builder exited for a recycle; spawning its replacement now");
180
139
  this.#spawnAfterRecycle = true;
@@ -220,20 +179,14 @@ export class IncrementalBuilderHost {
220
179
  this.#spawn(true);
221
180
  }, delay);
222
181
  }
223
- /**
224
- * Ask the builder to drain its queues and exit so the OS reclaims the bundler arenas `Bun.build`
225
- * never gives back; `onExit` then spawns the replacement. Graceful rather than `kill()` so a rebuild
226
- * in flight is never truncated, with a watchdog for a builder that stops answering.
227
- */
182
+ /** Drain-and-exit rather than `kill()`, so no rebuild is truncated; false when the request was not sent. */
228
183
  recycle(reason: string): boolean {
229
184
  if (!this.#proc || this.#status !== "ready" || this.#recycleRequested) return false;
230
185
  const proc = this.#proc;
231
186
  if (!this.send({ type: "builder-shutdown", reason })) return false;
232
187
  this.#recycleRequested = true;
233
- // From here the builder answers nothing new — it refuses every request that arrives during the
234
- // drain. Leaving the status at `ready` is what used to let those requests through to be refused,
235
- // one at a time, into the dev error page a recycle is supposed to be invisible to. `ready` the
236
- // field is deliberately untouched: `onExit` reads it to tell a planned exit from a boot failure.
188
+ // Not `ready`: the draining builder refuses new requests. The `ready` field stays, since `onExit` reads it to tell
189
+ // a planned exit from a boot failure.
237
190
  this.#status = "recycling";
238
191
  this.logger.debug(`recycling builder pid=${proc.pid} (${reason})`);
239
192
  this.#recycleTimer = setTimeout(() => {
@@ -252,11 +205,7 @@ export class IncrementalBuilderHost {
252
205
  clearTimeout(this.#recycleTimer);
253
206
  this.#recycleTimer = null;
254
207
  }
255
- /**
256
- * RSS at which the builder is recycled: an explicit override, else a share of the container's limit
257
- * (the builder is one of several dev processes), else the dev default. Set
258
- * `AKAN_BUILDER_MAX_RSS_MB=0` to leave it unbounded.
259
- */
208
+ /** RSS that triggers a recycle; `null` (`AKAN_BUILDER_MAX_RSS_MB=0`) means unbounded. */
260
209
  static maxRssBytes(): number | null {
261
210
  if (process.env.AKAN_BUILDER_MAX_RSS_MB === "0") return null;
262
211
  return MemoryLimit.resolveMaxRssBytes({
@@ -268,8 +217,7 @@ export class IncrementalBuilderHost {
268
217
  }
269
218
  send(message: BuilderMessage): boolean {
270
219
  if (!this.#proc || this.#status !== "ready") {
271
- // A builder on its way back is routine and the host holds what it refuses here, so only the
272
- // states nothing is recovering from are worth a warning.
220
+ // Recycling/restarting is routine (the caller holds the message), so only unrecoverable states warn.
273
221
  if (this.#status === "recycling" || this.#status === "restarting")
274
222
  this.logger.verbose(`incrementalBuilderHost is ${this.#status}; ${message.type} is for the replacement`);
275
223
  else this.logger.warn(`incrementalBuilderHost is ${this.#status}; cannot send ${message.type}`);
@@ -286,19 +234,13 @@ export class IncrementalBuilderHost {
286
234
  return false;
287
235
  }
288
236
  }
289
- /**
290
- * Answer every request the departing builder still owed, as if it had failed them itself. `onExit`
291
- * cannot do this alone: `stop()` clears `#proc` first, so the exit callback bails on its identity check.
292
- */
237
+ // Called from `stop()` too: it clears `#proc` first, so `onExit` bails on its identity check.
293
238
  #failInFlight(reason: string): void {
294
239
  if (!this.#inFlight.size) return;
295
240
  const lost = [...this.#inFlight];
296
241
  this.#inFlight.clear();
297
242
  this.logger.warn(`failing ${lost.length} unanswered builder request(s): ${reason}`);
298
- for (const [id, type] of lost) {
299
- if (type === "build-route") this.#onMessage({ type: "build-route-res", id, ok: false, error: reason });
300
- else this.#onMessage({ type: "build-csr-res", id, ok: false, error: reason });
301
- }
243
+ for (const [id, type] of lost) this.#onMessage({ type: `${type}-res`, id, ok: false, error: reason });
302
244
  }
303
245
  stop() {
304
246
  this.#manualStop = true;
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import type { BuilderMessage, BuilderReq } from "akanjs/server";
4
+ import { createTempApp, tempRoots, writeText } from "../testHelpers";
5
+
6
+ const track = tempRoots();
7
+
8
+ const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
9
+
10
+ const until = async (proc: Bun.Subprocess, what: string, condition: () => boolean) => {
11
+ const deadline = Date.now() + 20_000;
12
+ while (!condition()) {
13
+ if (proc.exitCode !== null || Date.now() > deadline) throw new Error(`the builder never sent ${what}`);
14
+ await wait(20);
15
+ }
16
+ };
17
+
18
+ describe("incremental builder process", () => {
19
+ test("says why and where a route's client bundle failed, in its answer, its build status and its log", async () => {
20
+ const { root } = track(await createTempApp("demo"));
21
+ const appDir = path.join(root, "apps/demo");
22
+ // The batch runner looks under the workspace root first: a stand-in for the boot build, which needs a whole app.
23
+ await writeText(
24
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
25
+ `const { generation } = JSON.parse(process.argv[2]);
26
+ process.send({ type: "build-batch-result", data: { generation, errors: {}, artifact: {}, optimizedFonts: { css: "", files: [] } } });
27
+ `,
28
+ );
29
+ const page = path.join(appDir, "page/_index.tsx");
30
+ await writeText(page, 'import { Broken } from "../ui/Broken";\nexport default Broken;\n');
31
+ await writeText(
32
+ path.join(appDir, "ui/Broken.tsx"),
33
+ '"use client";\nimport { gone } from "./not-there";\nexport const Broken = () => gone;\n',
34
+ );
35
+
36
+ const messages: BuilderMessage[] = [];
37
+ const proc = Bun.spawn(["bun", path.join(import.meta.dir, "incrementalBuilder.proc.ts")], {
38
+ cwd: appDir,
39
+ env: {
40
+ ...process.env,
41
+ AKAN_WORKSPACE_ROOT: root,
42
+ AKAN_PUBLIC_APP_NAME: "demo",
43
+ AKAN_PUBLIC_REPO_NAME: "repo",
44
+ AKAN_PUBLIC_SERVE_DOMAIN: "localhost",
45
+ AKAN_PUBLIC_ENV: "local",
46
+ AKAN_WATCH: "0",
47
+ },
48
+ stdio: ["ignore", "ignore", "pipe"],
49
+ serialization: "advanced",
50
+ ipc: (message: BuilderMessage) => {
51
+ messages.push(message);
52
+ },
53
+ });
54
+ try {
55
+ await until(proc, "builder-ready", () => messages.some((message) => message.type === "builder-ready"));
56
+ proc.send({
57
+ type: "build-route",
58
+ id: 1,
59
+ routeId: "page:/",
60
+ seeds: [page],
61
+ knownEntries: [],
62
+ generation: 3,
63
+ } satisfies BuilderReq);
64
+ await until(proc, "build-route-res", () => messages.some((message) => message.type === "build-route-res"));
65
+ } finally {
66
+ proc.kill();
67
+ }
68
+ const log = await new Response(proc.stderr).text();
69
+
70
+ const reason = '"./not-there" (apps/demo/ui/Broken.tsx:2:22)';
71
+ expect(messages.find((message) => message.type === "build-route-res")).toMatchObject({
72
+ id: 1,
73
+ ok: false,
74
+ error: expect.stringContaining(reason),
75
+ });
76
+ expect(messages.find((message) => message.type === "build-status")).toMatchObject({
77
+ data: { generation: 3, phase: "route", ok: false, message: expect.stringContaining(reason) },
78
+ });
79
+ expect(log).toContain(reason);
80
+ }, 30_000);
81
+ });