@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
@@ -3,23 +3,10 @@ import type { BaseBuildArtifact, CssPayload, PagesBundlePayload } from "akanjs/s
3
3
 
4
4
  export type OptimizedFonts = Awaited<ReturnType<FontOptimizer["optimize"]>>;
5
5
 
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
- */
6
+ // `base` is the boot build: it always runs alone, before the builder can serve anything.
12
7
  export type BuildBatchNeed = "base" | "pages" | "css" | "csr";
13
8
 
14
- /**
15
- * One generation of build work, handed to a process that exits when it is done.
16
- *
17
- * Everything here is JSON: the worker is spawned per batch, so state that the long-lived watcher
18
- * caches for the session has to travel by value. Two fields exist purely to keep behaviour identical
19
- * to the in-process version — `pageKeys` because rediscovering them costs ~220ms on a 177-route app
20
- * (route-export validation), and `optimizedFonts` because fonts are only re-optimized when a font file
21
- * actually changed.
22
- */
9
+ // JSON only: the worker is spawned per batch and receives this in its argv.
23
10
  export interface BuildBatchRequest {
24
11
  appName: string;
25
12
  workspaceRoot: string;
@@ -27,37 +14,22 @@ export interface BuildBatchRequest {
27
14
  generation: number;
28
15
  needs: BuildBatchNeed[];
29
16
  changedFiles: string[];
30
- /** Page keys the watcher already validated, or null to make the worker discover them itself. */
31
- pageKeys: string[] | null;
32
- /** Previous font optimization, reused unless this batch touched one of its files. */
33
- optimizedFonts: OptimizedFonts | null;
34
- /** Previous css assets, so an unchanged compile skips the broadcast instead of busting hashes. */
35
- cssAssets: PagesBatchCssAssets | null;
36
- /** Absolute artifact directory; the worker writes css assets under it. */
17
+ pageKeys: string[] | null; // null: the worker discovers them itself
18
+ optimizedFonts: OptimizedFonts | null; // reused unless this batch touched one of its files
19
+ cssAssets: PagesBatchCssAssets | null; // an unchanged compile skips the broadcast instead of busting hashes
37
20
  artifactDir: string;
38
21
  }
39
22
 
40
23
  export type PagesBatchCssAssets = CssPayload["cssAssets"];
41
24
 
42
- /**
43
- * What the watcher folds back into its own state once the worker is done.
44
- *
45
- * Deliberately small: the payloads browsers are waiting on are *streamed* as each need finishes
46
- * (`pages-updated`, `css-updated`, `build-status`, relayed straight through), so a page reload is not
47
- * held back by a css compile that has not started yet. Only state the next batch needs travels here.
48
- */
25
+ // Browser payloads stream as each need finishes; only state the next batch needs travels back here.
49
26
  export interface BuildBatchResult {
50
27
  generation: number;
51
28
  cssAssets?: PagesBatchCssAssets;
52
29
  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;
30
+ artifact?: BaseBuildArtifact; // `base` batches only
58
31
  errors: Partial<Record<BuildBatchNeed, string>>;
59
- /** The worker died before reporting, so it streamed no `build-status` of its own for these needs. */
60
- crashed?: boolean;
32
+ crashed?: boolean; // died before reporting, so it streamed no build-status for these needs
61
33
  }
62
34
 
63
35
  export type BuildBatchMessage = { type: "build-batch-result"; data: BuildBatchResult };
@@ -0,0 +1,34 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import path from "node:path";
3
+ import { createTempApp, tempRoots, writeText } from "../testHelpers";
4
+ import { BuildBatchRunner } from "./buildBatchRunner";
5
+
6
+ const track = tempRoots();
7
+
8
+ describe("BuildBatchRunner", () => {
9
+ test("reports a boot build the bundler refuses with where each reason sits in the workspace", async () => {
10
+ // Inside the checkout so `akanjs/server` resolves to its source: from the OS temp dir Bun auto-installs npm's 2.x.
11
+ const { root } = track(await createTempApp("demo", path.join(import.meta.dir, "..", "local")));
12
+ const appDir = path.join(root, "apps/demo");
13
+ await writeText(path.join(appDir, "env/env.client.ts"), "export const env = {} as const;\n");
14
+ await writeText(
15
+ path.join(appDir, "page/_index.tsx"),
16
+ 'import { gone } from "./not-there";\nexport default gone;\n',
17
+ );
18
+
19
+ const result = await new BuildBatchRunner({ workspaceRoot: root, cwd: appDir }).run({
20
+ appName: "demo",
21
+ workspaceRoot: root,
22
+ repoName: "repo",
23
+ generation: 0,
24
+ needs: ["base"],
25
+ changedFiles: [],
26
+ pageKeys: null,
27
+ optimizedFonts: null,
28
+ cssAssets: null,
29
+ artifactDir: path.join(appDir, ".akan/artifact"),
30
+ });
31
+
32
+ expect(result.errors.base).toContain('"./not-there" (apps/demo/page/_index.tsx:1:22)');
33
+ }, 30_000);
34
+ });
@@ -3,25 +3,9 @@ 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
- /**
7
- * Runs one `BuildBatchRequest` in a fresh process and resolves with what it produced.
8
- *
9
- * The watcher serializes every batch through its own work queue, so this deliberately has no pool: one
10
- * worker exists at a time, and it exits before the next one starts. That is the whole point — the
11
- * bundler arenas `Bun.build` never frees go back to the OS with the process.
12
- *
13
- * A worker that dies without reporting is not fatal. Every need it was given comes back as an error, the
14
- * watcher reports a red build-status for that generation, and the last-good artifact keeps serving —
15
- * the same contract a failed in-process build had. It must never take the watcher down with it: the
16
- * watcher is the dev server's file watcher, so nothing would notice the fix.
17
- */
6
+ // No pool on purpose: one worker at a time, exiting after its batch, returns the arenas `Bun.build` never frees.
7
+ // A worker that dies without reporting must not take the watcher down; its needs come back as errors.
18
8
  export class BuildBatchRunner {
19
- static readonly #entryCandidates = (workspaceRoot: string) => [
20
- path.join(workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
21
- path.join(workspaceRoot, "node_modules/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
22
- path.join(import.meta.dir, "buildBatch.proc.js"),
23
- path.join(import.meta.dir, "buildBatch.proc.ts"),
24
- ];
25
9
  #logger = new Logger("BuildBatchRunner");
26
10
  #entry: string | null = null;
27
11
  #workspaceRoot: string;
@@ -33,7 +17,12 @@ export class BuildBatchRunner {
33
17
 
34
18
  async #resolveEntry(): Promise<string> {
35
19
  if (this.#entry) return this.#entry;
36
- const candidates = BuildBatchRunner.#entryCandidates(this.#workspaceRoot);
20
+ const candidates = [
21
+ path.join(this.#workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
22
+ path.join(this.#workspaceRoot, "node_modules/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
23
+ path.join(import.meta.dir, "buildBatch.proc.js"),
24
+ path.join(import.meta.dir, "buildBatch.proc.ts"),
25
+ ];
37
26
  for (const candidate of candidates) {
38
27
  if (!(await Bun.file(candidate).exists())) continue;
39
28
  this.#entry = candidate;
@@ -42,11 +31,6 @@ export class BuildBatchRunner {
42
31
  throw new Error(`[build-batch] worker entry not found; looked in: ${candidates.join(", ")}`);
43
32
  }
44
33
 
45
- /**
46
- * `onMessage` receives everything the worker streams as it goes — `pages-updated`, `css-updated`,
47
- * `build-status` — so the watcher can relay each one the moment it is produced instead of holding a
48
- * page reload until the whole batch is done.
49
- */
50
34
  async run(
51
35
  request: BuildBatchRequest,
52
36
  onMessage: (message: BuilderMessage) => void = () => undefined,
@@ -54,8 +38,7 @@ export class BuildBatchRunner {
54
38
  const started = Date.now();
55
39
  const entry = await this.#resolveEntry();
56
40
  let result: BuildBatchResult | null = null;
57
- // The request travels in argv rather than over IPC so the worker can start on its first tick
58
- // instead of waiting for a handshake it would have to synchronize against.
41
+ // argv rather than IPC, so the worker starts on its first tick instead of waiting for a handshake.
59
42
  const proc = Bun.spawn(["bun", entry, JSON.stringify(request)], {
60
43
  cwd: this.#cwd,
61
44
  env: process.env,
@@ -74,11 +57,7 @@ export class BuildBatchRunner {
74
57
  );
75
58
  return result;
76
59
  }
77
- // A worker the kernel OOM-killed exits with code `null` and `SIGKILL`, which without the signal
78
- // reads exactly like an ordinary crash — and the two have opposite fixes: one is a build error to
79
- // find, the other is a memory limit to raise. The peak here is the largest transient in the tree
80
- // (a boot build measured 548MB on a tenant app, 1.1GB on apps/akan), so on a small sandbox this is
81
- // the process the kernel reaches for first.
60
+ // An OOM kill (code null, SIGKILL) reads like a crash without the signal, and needs the opposite fix.
82
61
  const message = proc.signalCode
83
62
  ? `build worker was killed by ${proc.signalCode} before reporting a result${
84
63
  proc.signalCode === "SIGKILL" ? " — most often the kernel OOM killer; check the sandbox's memory limit" : ""
@@ -1,30 +1,18 @@
1
- import { afterEach, describe, expect, test } from "bun:test";
2
- import { mkdtemp, rm } from "node:fs/promises";
3
- import os from "node:os";
1
+ import { describe, expect, test } from "bun:test";
4
2
  import path from "node:path";
3
+ import { tempDirs } from "../testHelpers";
5
4
  import { BuilderChannel } from "./builderChannel";
6
5
 
7
- const tempDirs: string[] = [];
8
-
9
- afterEach(async () => {
10
- for (const dir of tempDirs.splice(0)) await rm(dir, { recursive: true, force: true });
11
- });
6
+ const tempDir = tempDirs("builder-channel-");
12
7
 
13
8
  type SendMode = "drain" | "await" | "bare";
14
9
 
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
- */
10
+ // Shape decides whether a bare send survives: `manifest` is many short strings, `css` one long string.
22
11
  const sendThenExit = async (
23
12
  bytes: number,
24
13
  { mode, payload }: { mode: SendMode; payload: "manifest" | "css" },
25
14
  ): Promise<Array<{ type?: string }>> => {
26
- const dir = await mkdtemp(path.join(os.tmpdir(), "builder-channel-"));
27
- tempDirs.push(dir);
15
+ const dir = await tempDir();
28
16
  const entry = path.join(dir, "child.ts");
29
17
  const modulePath = JSON.stringify(path.join(import.meta.dir, "builderChannel"));
30
18
  const build =
@@ -63,44 +51,42 @@ const sendThenExit = async (
63
51
  return received;
64
52
  };
65
53
 
54
+ const withDeferredFlushes = async (fn: (flushes: Array<() => void>) => Promise<void>) => {
55
+ const send = process.send;
56
+ const flushes: Array<() => void> = [];
57
+ try {
58
+ (process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
59
+ flushes.push(cb);
60
+ return true;
61
+ };
62
+ await fn(flushes);
63
+ } finally {
64
+ (process as { send?: unknown }).send = send;
65
+ }
66
+ };
67
+
66
68
  describe("BuilderChannel", () => {
67
69
  test("delivers a reply too large for the pipe buffer before the process exits", async () => {
68
70
  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
71
  expect(await sendThenExit(0, { mode: "await", payload: "manifest" })).toMatchObject([{ id: 7 }]);
71
72
  });
72
73
 
73
74
  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
75
  expect(await sendThenExit(200_000, { mode: "drain", payload: "css" })).toMatchObject([{ type: "css-updated" }]);
77
76
  });
78
77
 
79
- /**
80
- * darwin only, measured: on Linux a 1MB message sent immediately before `process.exit` **arrives in
81
- * full**, so the loss this control demonstrates does not reproduce there and the assertion would fail
82
- * for the right reason on the wrong platform. `BuilderChannel` itself stays correct and costs nothing
83
- * on Linux — the bug it prevents is one macOS developers hit and a Linux fleet does not.
84
- */
78
+ // darwin only: on Linux a message sent right before `process.exit` arrives in full, so the loss does not reproduce.
85
79
  test.skipIf(process.platform !== "darwin")(
86
80
  "without the flush wait the same messages are lost, which is why this class exists",
87
81
  async () => {
88
82
  // Controls, not requirements: if a future bun flushes ipc writes on exit, these fail and say so.
89
83
  expect(await sendThenExit(1_000_000, { mode: "bare", payload: "manifest" })).toEqual([]);
90
- // 20KB of css, far below the 64KB the manifest shape survives — one long string dies much earlier,
91
- // so no size threshold would have been safe to special-case.
92
84
  expect(await sendThenExit(20_000, { mode: "bare", payload: "css" })).toEqual([]);
93
85
  },
94
86
  );
95
87
 
96
88
  test("drain resolves only once every tracked send has flushed", async () => {
97
- const send = process.send;
98
- const flushes: Array<() => void> = [];
99
- try {
100
- (process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
101
- flushes.push(cb);
102
- return true;
103
- };
89
+ await withDeferredFlushes(async (flushes) => {
104
90
  BuilderChannel.emit({ type: "builder-ready" });
105
91
  BuilderChannel.emit({ type: "builder-ready" });
106
92
  let drained = false;
@@ -113,31 +99,20 @@ describe("BuilderChannel", () => {
113
99
  expect(drained).toBe(false);
114
100
  flushes[1]?.();
115
101
  expect(await draining).toBe(2);
116
- } finally {
117
- (process as { send?: unknown }).send = send;
118
- }
102
+ });
119
103
  });
120
104
 
121
105
  test("a send started while draining is drained too", async () => {
122
- const send = process.send;
123
- const flushes: Array<() => void> = [];
124
- try {
125
- (process as { send?: unknown }).send = (_msg: unknown, _h: unknown, _o: unknown, cb: () => void) => {
126
- flushes.push(cb);
127
- return true;
128
- };
106
+ await withDeferredFlushes(async (flushes) => {
129
107
  BuilderChannel.emit({ type: "builder-ready" });
130
108
  const draining = BuilderChannel.drain();
131
- // What `#reportMetrics` does from a work item's `finally`, after the drain has begun.
132
109
  BuilderChannel.emit({ type: "builder-metrics", data: { rssBytes: 1, generation: 1, workCount: 1 } });
133
110
  for (let i = 0; i < 4 && flushes.length; i++) {
134
111
  flushes.shift()?.();
135
112
  await Bun.sleep(1);
136
113
  }
137
114
  expect(await draining).toBe(2);
138
- } finally {
139
- (process as { send?: unknown }).send = send;
140
- }
115
+ });
141
116
  });
142
117
 
143
118
  test("resolves instead of hanging when there is no ipc channel", async () => {
@@ -1,42 +1,18 @@
1
1
  import type { BuilderMessage } from "akanjs/server";
2
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
- */
3
+ // `process.exit` drops an ipc write that has not flushed (a natural exit does not), and whether one survives
4
+ // depends on the message's shape as well as its size — so every send reports its flush and `shutdown` drains.
23
5
  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
- */
6
+ // Bounded so a runtime that never calls the flush callback costs one late message, not a wedged drain.
28
7
  static readonly #flushTimeoutMs = 5_000;
29
- /** Sends that have not flushed yet; `drain` only ever needs to await them all. */
30
8
  static readonly #flushing = new Set<Promise<void>>();
31
9
 
32
- /** Send, and resolve once the write has left this process. */
33
10
  static send(message: BuilderMessage): Promise<void> {
34
11
  const send = process.send;
35
12
  if (!send) return Promise.resolve();
36
13
  const flushed = new Promise<void>((resolve) => {
37
14
  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.
15
+ // No retry: the host answers an orphaned request id on exit, and a lost event is superseded next generation.
40
16
  send.call(process, message, undefined, undefined, () => {
41
17
  clearTimeout(timer);
42
18
  resolve();
@@ -47,19 +23,12 @@ export class BuilderChannel {
47
23
  return flushed;
48
24
  }
49
25
 
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
- */
26
+ // Tracked like `send`: for an event nobody awaits, `drain` is all that stands between it and the exit.
54
27
  static emit(message: BuilderMessage): void {
55
28
  void BuilderChannel.send(message);
56
29
  }
57
30
 
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
- */
31
+ // Loops because `#reportMetrics` can send from a work item's `finally` after the drain has begun.
63
32
  static async drain(): Promise<number> {
64
33
  let flushed = 0;
65
34
  while (BuilderChannel.#flushing.size) {
@@ -10,7 +10,6 @@ const routeReq = (id: number, routeId: string): BuilderReq => ({
10
10
  knownEntries: [],
11
11
  });
12
12
 
13
- /** `routeId` is what tells one generation's answer from the other's; the rest is an empty delta. */
14
13
  const routeRes = (id: number, routeId: string): Extract<BuilderRes, { ok: true }> => ({
15
14
  type: "build-route-res",
16
15
  id,
@@ -23,7 +22,6 @@ describe("BuilderRequestRouter", () => {
23
22
  const router = new BuilderRequestRouter();
24
23
  router.startGeneration();
25
24
 
26
- // Everything but the id travels untouched; the builder never learns it was renumbered.
27
25
  const first = router.issue(routeReq(1, "/home"));
28
26
  const second = router.issue(routeReq(2, "/about"));
29
27
  expect(first.routeId).toBe("/home");
@@ -35,11 +33,6 @@ describe("BuilderRequestRouter", () => {
35
33
  expect(router.inFlightCount).toBe(0);
36
34
  });
37
35
 
38
- /**
39
- * The defect this class exists for. `BuilderRpc` numbers from 1 in every backend process, so without
40
- * renumbering the second generation's `id: 1` request is settled by the first generation's answer —
41
- * a page rendered against another route's client manifest — and its own answer is then dropped.
42
- */
43
36
  test("does not deliver a dead generation's answer to the backend that replaced it", () => {
44
37
  const router = new BuilderRequestRouter();
45
38
  router.startGeneration();
@@ -70,7 +63,6 @@ describe("BuilderRequestRouter", () => {
70
63
  router.startGeneration();
71
64
  const outgoing = router.issue(routeReq(4, "/home"));
72
65
 
73
- // The caller answers the backend itself in this case, so a late builder answer must not answer it again.
74
66
  router.withdraw(outgoing.id);
75
67
  expect(router.inFlightCount).toBe(0);
76
68
  expect(router.settle(routeRes(outgoing.id, "/home"))).toBeNull();
@@ -3,19 +3,8 @@ import type { BuilderCsrReq, BuilderCsrRes, BuilderReq, BuilderRes } from "akanj
3
3
  type BuilderRequest = BuilderReq | BuilderCsrReq;
4
4
  type BuilderResponse = BuilderRes | BuilderCsrRes;
5
5
 
6
- /**
7
- * Correlation ids for builder requests, renumbered by the dev host so they survive a backend restart.
8
- *
9
- * `BuilderRpc` numbers its requests from 1 in each backend *process*, and the host relays them to a
10
- * builder that outlives the backend. So after a restart the two generations collide on id 1: the builder
11
- * answers the request the *previous* backend made, the host relays it, and the new backend settles its own
12
- * request of that number with another route's manifest delta — a page rendered against client modules that
13
- * were never built for it. The answer it was actually waiting for then arrives to an empty pending map and
14
- * is dropped, so the correct build is discarded too.
15
- *
16
- * A host-owned id fixes it without touching the protocol: neither the backend nor the builder learns that
17
- * anything was renumbered, and a response whose generation is gone is discarded instead of misdelivered.
18
- */
6
+ // `BuilderRpc` numbers from 1 per backend process while the builder outlives backends, so ids collide across a
7
+ // restart; host-owned ids keep a dead generation's answer from settling the new backend's request.
19
8
  export class BuilderRequestRouter {
20
9
  #generation = 0;
21
10
  #nextId = 1;
@@ -29,33 +18,25 @@ export class BuilderRequestRouter {
29
18
  return this.#inFlight.size;
30
19
  }
31
20
 
32
- /**
33
- * Begin a new backend generation, abandoning every request the previous one owned.
34
- *
35
- * Nothing is answered on the way out: the process that asked is gone, and its `BuilderRpc` went with it.
36
- */
21
+ // Abandons the previous generation's requests unanswered: the backend that asked is gone.
37
22
  startGeneration(): number {
38
23
  this.#generation += 1;
39
24
  this.#inFlight.clear();
40
25
  return this.#generation;
41
26
  }
42
27
 
43
- /** The request to forward to the builder, carrying this host's id in place of the backend's. */
44
28
  issue<T extends BuilderRequest>(message: T): T {
45
29
  const id = this.#nextId++;
46
30
  this.#inFlight.set(id, { backendId: message.id, generation: this.#generation });
47
31
  return { ...message, id };
48
32
  }
49
33
 
50
- /** Give an id back when the send failed, so the caller answers the backend itself. */
34
+ // For a failed send, after which the caller answers the backend itself.
51
35
  withdraw(id: number): void {
52
36
  this.#inFlight.delete(id);
53
37
  }
54
38
 
55
- /**
56
- * The response to forward to the backend with its own id restored, or `null` when no live backend is
57
- * waiting for it — either it was never issued or the generation that issued it has been replaced.
58
- */
39
+ // null when no live backend is waiting: never issued, or its generation was replaced.
59
40
  settle<T extends BuilderResponse>(message: T): T | null {
60
41
  const request = this.#inFlight.get(message.id);
61
42
  if (!request) return null;
@@ -1,19 +1,22 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import path from "node:path";
3
- import { DevChangePlanner } from "../frontendBuild";
3
+ import { DevChangePlanner, type GeneratedIndexSyncResult } from "../frontendBuild";
4
4
  import { prepareDevWatchBatch } from "./devWatchBatch";
5
5
 
6
+ const prepare = (root: string, generation: number, file: string, indexSync: GeneratedIndexSyncResult) =>
7
+ prepareDevWatchBatch({
8
+ generation,
9
+ batch: { files: [file], kinds: new Set(["code"]) },
10
+ indexSync,
11
+ changePlanner: new DevChangePlanner({ workspaceRoot: root }),
12
+ });
13
+
6
14
  describe("prepareDevWatchBatch", () => {
7
15
  test("includes generated indexes in the same invalidate generation", () => {
8
16
  const root = path.resolve("/repo");
9
17
  const changedFile = path.join(root, "libs/shared/common/foo.ts");
10
18
  const generatedIndex = path.join(root, "libs/shared/common/index.ts");
11
- const prepared = prepareDevWatchBatch({
12
- generation: 12,
13
- batch: { files: [changedFile], kinds: new Set(["code"]) },
14
- indexSync: { changedFiles: [generatedIndex], errors: [] },
15
- changePlanner: new DevChangePlanner({ workspaceRoot: root }),
16
- });
19
+ const prepared = prepare(root, 12, changedFile, { changedFiles: [generatedIndex], errors: [] });
17
20
 
18
21
  expect(prepared.hasSyncErrors).toBe(false);
19
22
  expect(prepared.files).toEqual([changedFile, generatedIndex]);
@@ -29,12 +32,7 @@ describe("prepareDevWatchBatch", () => {
29
32
  const root = path.resolve("/repo");
30
33
  const changedFile = path.join(root, "libs/shared", facet, "tmpExample.ts");
31
34
  const generatedIndex = path.join(root, "libs/shared", facet, "index.ts");
32
- const prepared = prepareDevWatchBatch({
33
- generation: 20,
34
- batch: { files: [changedFile], kinds: new Set(["code"]) },
35
- indexSync: { changedFiles: [generatedIndex], errors: [] },
36
- changePlanner: new DevChangePlanner({ workspaceRoot: root }),
37
- });
35
+ const prepared = prepare(root, 20, changedFile, { changedFiles: [generatedIndex], errors: [] });
38
36
 
39
37
  expect(new Set(prepared.files)).toEqual(new Set([changedFile, generatedIndex]));
40
38
  expect(prepared.event.devPlan?.generatedFiles).toEqual([generatedIndex]);
@@ -45,11 +43,9 @@ describe("prepareDevWatchBatch", () => {
45
43
 
46
44
  test("marks failed generated index sync as an error generation", () => {
47
45
  const root = "/repo";
48
- const prepared = prepareDevWatchBatch({
49
- generation: 13,
50
- batch: { files: [`${root}/libs/shared/common/foo.ts`], kinds: new Set(["code"]) },
51
- indexSync: { changedFiles: [], errors: ["sync failed"] },
52
- changePlanner: new DevChangePlanner({ workspaceRoot: root }),
46
+ const prepared = prepare(root, 13, `${root}/libs/shared/common/foo.ts`, {
47
+ changedFiles: [],
48
+ errors: ["sync failed"],
53
49
  });
54
50
 
55
51
  expect(prepared.hasSyncErrors).toBe(true);