@akanjs/devkit 3.0.0-beta.9 → 3.0.0

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 (341) hide show
  1. package/DEV_RUNTIME_KNOBS.md +44 -26
  2. package/README.ko.md +21 -7
  3. package/README.md +17 -8
  4. package/abstractDoc.test.ts +6 -15
  5. package/abstractDoc.ts +6 -8
  6. package/agentsIndex.test.ts +4 -7
  7. package/agentsIndex.ts +6 -22
  8. package/akanApp/BackendImportGraph.test.ts +8 -22
  9. package/akanApp/BackendImportGraph.ts +1 -20
  10. package/akanApp/akanApp.host.test.ts +56 -3
  11. package/akanApp/akanApp.host.ts +218 -278
  12. package/akanApp/devBootLatch.test.ts +39 -0
  13. package/akanApp/devBootLatch.ts +39 -0
  14. package/akanApp/devHostPolicy.test.ts +81 -35
  15. package/akanApp/devHostPolicy.ts +41 -102
  16. package/akanConfig/akanBin.test.ts +62 -0
  17. package/akanConfig/akanBin.ts +78 -0
  18. package/akanConfig/akanConfig.test.ts +439 -141
  19. package/akanConfig/akanConfig.ts +359 -275
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +22 -8
  22. package/akanContext.ts +110 -160
  23. package/akanMcpContract.ts +28 -73
  24. package/appSelectionMemory.ts +1 -4
  25. package/applicationBuildReporter.ts +37 -5
  26. package/applicationBuildRunner.test.ts +41 -5
  27. package/applicationBuildRunner.ts +71 -63
  28. package/applicationTestPreload.ts +3 -12
  29. package/artifact/implicitRootLayout.test.ts +32 -30
  30. package/artifact/implicitRootLayout.ts +58 -106
  31. package/artifact/routeSeedIndex.test.ts +7 -6
  32. package/artifact/routeSeedIndex.ts +11 -44
  33. package/biome.base.json +35 -2
  34. package/biomeBase.ts +2 -5
  35. package/cloud/cloudApi.test.ts +202 -0
  36. package/cloud/cloudApi.ts +77 -39
  37. package/cloud/configLock.test.ts +64 -0
  38. package/cloud/configLock.ts +96 -0
  39. package/cloud/constants.ts +26 -5
  40. package/cloud/globalConfig.ts +71 -32
  41. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  42. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  43. package/codeAgent/agent/CodeAgent.ts +551 -0
  44. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  45. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  46. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  47. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  48. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  49. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  50. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  51. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  52. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  53. package/codeAgent/agent/CodeMailbox.ts +148 -0
  54. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  55. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  56. package/codeAgent/agent/SubagentPool.ts +214 -0
  57. package/codeAgent/agent/akanCodeModel.ts +47 -0
  58. package/codeAgent/agent/akanCodePaths.ts +31 -0
  59. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  60. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  61. package/codeAgent/agent/codeAgent.test.ts +178 -0
  62. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  63. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  64. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  65. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  66. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  67. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  68. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  69. package/codeAgent/agent/consoleToStderr.ts +8 -0
  70. package/codeAgent/agent/jsonLines.ts +29 -0
  71. package/codeAgent/agent/stringArg.ts +5 -0
  72. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  73. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  74. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  75. package/codeAgent/feedback/PreviewView.ts +95 -0
  76. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  77. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  78. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  79. package/codeAgent/index.ts +34 -0
  80. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  81. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  82. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  83. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  84. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  85. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  86. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  87. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  88. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  89. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  90. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  91. package/codeAgent/tools/AkanCli.ts +50 -0
  92. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  93. package/codeAgent/tools/AkanEditScope.ts +48 -0
  94. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  95. package/codeAgent/tools/AkanToolPack.ts +92 -0
  96. package/codeAgent/tools/AkanVerifier.ts +48 -0
  97. package/codeAgent/tools/AskToolPack.ts +99 -0
  98. package/codeAgent/tools/MailToolPack.ts +76 -0
  99. package/codeAgent/tools/McpClient.ts +187 -0
  100. package/codeAgent/tools/McpOAuth.ts +233 -0
  101. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  102. package/codeAgent/tools/McpServerConfig.ts +129 -0
  103. package/codeAgent/tools/McpSignIn.ts +158 -0
  104. package/codeAgent/tools/McpTokenStore.ts +81 -0
  105. package/codeAgent/tools/McpToolPack.ts +199 -0
  106. package/codeAgent/tools/SessionToolPack.ts +91 -0
  107. package/codeAgent/tools/WebToolPack.ts +147 -0
  108. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  109. package/codeAgent/tools/askToolPack.test.ts +116 -0
  110. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  111. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  112. package/codegenLock.test.ts +4 -13
  113. package/codegenLock.ts +3 -17
  114. package/commandDecorators/argMeta.ts +1 -5
  115. package/commandDecorators/camelToKebabCase.ts +1 -0
  116. package/commandDecorators/command.ts +46 -102
  117. package/commandDecorators/commandBuilder.ts +30 -61
  118. package/commandDecorators/commandDecorators.test.ts +2 -10
  119. package/commandDecorators/dependencyBuilder.ts +10 -1
  120. package/commandDecorators/helpFormatter.ts +68 -145
  121. package/commandDecorators/index.ts +0 -2
  122. package/commandDecorators/targetMeta.ts +9 -7
  123. package/createTunnel.ts +7 -14
  124. package/csrE2e/csrE2eHarness.fixture.ts +505 -0
  125. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  126. package/csrE2e/csrPageStack.e2e.test.ts +252 -0
  127. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  128. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  129. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  130. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  131. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  132. package/dependencyScanner.test.ts +4 -16
  133. package/dependencyScanner.ts +31 -80
  134. package/devkitUtils.test.ts +48 -20
  135. package/executors.linkFallback.test.ts +103 -0
  136. package/executors.test.ts +176 -106
  137. package/executors.ts +212 -498
  138. package/fileEditor.ts +9 -37
  139. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  140. package/fileSys.ts +22 -9
  141. package/formSetterScanner.ts +3 -16
  142. package/frontendBuild/allRoutesBuilder.ts +7 -21
  143. package/frontendBuild/autoImportSync.test.ts +27 -38
  144. package/frontendBuild/autoImportSync.ts +31 -65
  145. package/frontendBuild/buildRouteClient.test.ts +123 -37
  146. package/frontendBuild/bundleDefine.test.ts +12 -0
  147. package/frontendBuild/bundleDefine.ts +16 -0
  148. package/frontendBuild/clientBuildTypes.ts +5 -36
  149. package/frontendBuild/clientEntriesBundler.ts +9 -40
  150. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  151. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  152. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  153. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  154. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  155. package/frontendBuild/csrDevBundler.test.ts +66 -0
  156. package/frontendBuild/csrDevBundler.ts +95 -0
  157. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  158. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  159. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  160. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  161. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  162. package/frontendBuild/csrDevPatcher.ts +548 -0
  163. package/frontendBuild/csrDevPaths.ts +145 -0
  164. package/frontendBuild/csrDevResolver.test.ts +239 -0
  165. package/frontendBuild/csrDevResolver.ts +363 -0
  166. package/frontendBuild/csrDevTypes.ts +61 -0
  167. package/frontendBuild/csrEntryFiles.ts +24 -0
  168. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  169. package/frontendBuild/cssCandidateCache.ts +6 -41
  170. package/frontendBuild/cssCompiler.ts +31 -82
  171. package/frontendBuild/cssImportResolver.ts +6 -19
  172. package/frontendBuild/devChangePlanner.ts +7 -10
  173. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  174. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  175. package/frontendBuild/devRegistryBundler.ts +192 -0
  176. package/frontendBuild/fontOptimizer.test.ts +6 -14
  177. package/frontendBuild/fontOptimizer.ts +15 -72
  178. package/frontendBuild/fontPruner.test.ts +5 -10
  179. package/frontendBuild/fontPruner.ts +5 -20
  180. package/frontendBuild/frontendBuild.test.ts +57 -70
  181. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  182. package/frontendBuild/hmrWatcher.test.ts +59 -32
  183. package/frontendBuild/hmrWatcher.ts +31 -74
  184. package/frontendBuild/index.ts +4 -0
  185. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  186. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  187. package/frontendBuild/precompressArtifacts.ts +5 -18
  188. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  189. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  190. package/frontendBuild/routeClientBuilder.ts +100 -62
  191. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  192. package/frontendBuild/serverGraphFile.test.ts +77 -0
  193. package/frontendBuild/serverGraphFile.ts +93 -0
  194. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  195. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  196. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  197. package/frontendBuild/ssrDevBundler.ts +85 -0
  198. package/frontendBuild/styleContract.ts +1 -8
  199. package/frontendBuild/themeValidator.ts +4 -26
  200. package/frontendBuild/watchRootResolver.test.ts +23 -42
  201. package/frontendBuild/watchRootResolver.ts +5 -27
  202. package/getRelatedCnsts.ts +12 -92
  203. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  204. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  205. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  206. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  207. package/incrementalBuilder/builderChannel.test.ts +24 -49
  208. package/incrementalBuilder/builderChannel.ts +6 -37
  209. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  210. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  211. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  212. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  213. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  214. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  215. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  216. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  217. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  218. package/index.ts +5 -19
  219. package/integration/devResourceProbe.ts +50 -56
  220. package/integration/devStability.integration.test.ts +105 -199
  221. package/integration/devStabilityHarness.test.ts +13 -28
  222. package/integration/devStabilityHarness.ts +65 -330
  223. package/integration/ssrMemoryProbe.ts +41 -167
  224. package/libSource.test.ts +4 -15
  225. package/libSource.ts +19 -31
  226. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  227. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  228. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  229. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  230. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  231. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  232. package/lint/gritRules.test.ts +8 -24
  233. package/lint/no-deep-internal-import.grit +2 -2
  234. package/lint/no-document-cookie.grit +29 -0
  235. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  236. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  237. package/lint/no-web-storage.grit +30 -0
  238. package/linter.test.ts +1 -2
  239. package/linter.ts +26 -116
  240. package/mobile/desktopBin.test.ts +182 -0
  241. package/mobile/desktopBin.ts +144 -0
  242. package/mobile/desktopServerStage.test.ts +252 -0
  243. package/mobile/desktopServerStage.ts +178 -0
  244. package/mobile/index.ts +9 -0
  245. package/mobile/mobileTarget.test.ts +57 -8
  246. package/mobile/mobileTarget.ts +45 -26
  247. package/mobile/nativeApi.test.ts +30 -0
  248. package/mobile/nativeApi.ts +40 -0
  249. package/mobile/nativeApp.test.ts +184 -0
  250. package/mobile/nativeApp.ts +454 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +438 -0
  254. package/mobile/nativeConfig.ts +294 -0
  255. package/mobile/nativePluginFolders.test.ts +81 -0
  256. package/mobile/nativePluginFolders.ts +60 -0
  257. package/mobile/nativeWebDir.test.ts +80 -0
  258. package/mobile/nativeWebDir.ts +66 -0
  259. package/mobile/placeholderAppId.test.ts +13 -0
  260. package/mobile/placeholderAppId.ts +16 -0
  261. package/mobile/usageDescriptions.test.ts +27 -0
  262. package/mobile/usageDescriptions.ts +24 -0
  263. package/package.json +11 -11
  264. package/packageExports.test.ts +3 -18
  265. package/packageExportsMap.ts +3 -19
  266. package/piDependencyPins.test.ts +46 -0
  267. package/platformTest/BunTestReport.test.ts +60 -0
  268. package/platformTest/BunTestReport.ts +65 -0
  269. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  270. package/platformTest/IdleWatchedProcess.ts +73 -0
  271. package/platformTest/LinuxDockerTarget.ts +105 -0
  272. package/platformTest/PlatformTestRun.test.ts +64 -0
  273. package/platformTest/PlatformTestRun.ts +141 -0
  274. package/platformTest/PlatformTestTarget.ts +142 -0
  275. package/platformTest/TestSnapshot.test.ts +72 -0
  276. package/platformTest/TestSnapshot.ts +103 -0
  277. package/platformTest/WindowsSshTarget.ts +151 -0
  278. package/prompter.ts +3 -13
  279. package/qualityScanner.test.ts +9 -26
  280. package/qualityScanner.ts +200 -286
  281. package/recipeScanner.test.ts +7 -12
  282. package/recipeScanner.ts +9 -38
  283. package/repoIdentity.ts +2 -9
  284. package/routeSourceValidator.test.ts +0 -1
  285. package/routeSourceValidator.ts +7 -42
  286. package/scanInfo.ts +91 -225
  287. package/selectModel.ts +1 -2
  288. package/semver.ts +3 -5
  289. package/slicePlanner.test.ts +6 -24
  290. package/slicePlanner.ts +11 -32
  291. package/spinner.test.ts +3 -8
  292. package/spinner.ts +4 -16
  293. package/sshTunnel.ts +1 -6
  294. package/ssrScanner.test.ts +1 -6
  295. package/ssrScanner.ts +103 -113
  296. package/stripAnsi.ts +3 -0
  297. package/subspace.test.ts +9 -25
  298. package/subspace.ts +48 -115
  299. package/subspaceConfig.ts +3 -11
  300. package/testHelpers.ts +194 -0
  301. package/transforms/asyncDefaultExportDetector.ts +3 -11
  302. package/transforms/barrelAnalyzer.ts +43 -135
  303. package/transforms/barrelImportsPlugin.ts +38 -175
  304. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  305. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  306. package/transforms/moduleSyntax.ts +32 -0
  307. package/transforms/rscUseClientTransform.ts +42 -20
  308. package/transforms/serverEnvPlugin.test.ts +103 -0
  309. package/transforms/serverEnvPlugin.ts +41 -0
  310. package/transforms/transforms.test.ts +166 -44
  311. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  312. package/transforms/useClientBundlePlugin.ts +6 -23
  313. package/typeChecker.ts +7 -63
  314. package/types.ts +2 -0
  315. package/uploadRelease.ts +10 -25
  316. package/workflow/artifacts.ts +18 -0
  317. package/workflow/executor.test.ts +1 -12
  318. package/workflow/executor.ts +101 -110
  319. package/workflow/index.ts +0 -1
  320. package/workflow/moduleIndex.test.ts +4 -15
  321. package/workflow/moduleIndex.ts +9 -49
  322. package/workflow/plan.ts +18 -25
  323. package/workflow/render.ts +80 -96
  324. package/workflow/rolloutGate.test.ts +1 -1
  325. package/workflow/source.ts +52 -69
  326. package/workflow/sourceAst.ts +50 -0
  327. package/workspaceLayout.test.ts +18 -3
  328. package/workspaceLayout.ts +22 -23
  329. package/abstractCompactor.ts +0 -112
  330. package/aiEditor.test.ts +0 -60
  331. package/aiEditor.ts +0 -387
  332. package/applicationReleasePackager.ts +0 -209
  333. package/capacitorApp.test.ts +0 -372
  334. package/capacitorApp.ts +0 -1648
  335. package/commandDecorators/commandMeta.ts +0 -7
  336. package/commandDecorators/types.ts +0 -10
  337. package/getDirname.ts +0 -4
  338. package/guideline.ts +0 -19
  339. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  340. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  341. package/workflow/primitive.ts +0 -59
@@ -1,19 +1,22 @@
1
+ import { rm } from "node:fs/promises";
1
2
  import path from "node:path";
2
- // Module paths, never a barrel. The `@akanjs/devkit` root re-exports all 41 modules, which would drag
3
- // @trapezedev/project, the @langchain stack, ssh2, ink and the cloud stack in; and `frontendBuild`'s own
4
- // barrel reaches `cssCompiler`/`ssrBaseArtifactBuilder`, which pull tailwindcss + @tailwindcss/node
5
- // (~40MB) into a process that then holds them for the whole dev session. Phase 2 moved css compilation
6
- // into the batch worker, so this process has no use for them — `entryModuleGraph.test.ts` keeps it that way.
3
+ // Module paths, never a barrel: the devkit and `frontendBuild` barrels would hold tailwindcss (~40MB), ssh2, ink
4
+ // and the cloud stack for the whole dev session (`entryModuleGraph.test.ts` enforces it).
5
+ import { ApplicationBuildReporter } from "@akanjs/devkit/applicationBuildReporter";
7
6
  import { CodegenLock } from "@akanjs/devkit/codegenLock";
8
7
  import type { App } from "@akanjs/devkit/commandDecorators";
9
8
  import { AppExecutor, type PageRoot, WorkspaceExecutor } from "@akanjs/devkit/executors";
10
9
  import { AutoImportSync } from "@akanjs/devkit/frontendBuild/autoImportSync";
11
10
  import type { ClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientBuildTypes";
12
11
  import { GraphClientEntryDiscovery } from "@akanjs/devkit/frontendBuild/clientEntryDiscovery";
12
+ import { CsrDevBundler } from "@akanjs/devkit/frontendBuild/csrDevBundler";
13
+ import { CsrDevPatcher } from "@akanjs/devkit/frontendBuild/csrDevPatcher";
13
14
  import { DevChangePlanner } from "@akanjs/devkit/frontendBuild/devChangePlanner";
14
15
  import { DevGeneratedIndexSync } from "@akanjs/devkit/frontendBuild/devGeneratedIndexSync";
15
16
  import { HmrWatcher } from "@akanjs/devkit/frontendBuild/hmrWatcher";
16
17
  import { RouteClientBuilder } from "@akanjs/devkit/frontendBuild/routeClientBuilder";
18
+ import { ServerGraphFile } from "@akanjs/devkit/frontendBuild/serverGraphFile";
19
+ import { SsrDevBundler } from "@akanjs/devkit/frontendBuild/ssrDevBundler";
17
20
  import { WatchRootResolver } from "@akanjs/devkit/frontendBuild/watchRootResolver";
18
21
  import { Logger } from "akanjs/common";
19
22
  import type {
@@ -25,10 +28,13 @@ import type {
25
28
  BuildPhase,
26
29
  BuildRouteResultPayload,
27
30
  ChangeBatch,
31
+ HmrTrace,
28
32
  } from "akanjs/server";
33
+ import { CSR_DEV_PATCHING_MARKER } from "akanjs/server/hmr/csrDevManifest";
29
34
  import type { BuildBatchNeed, BuildBatchRequest, BuildBatchResult, OptimizedFonts } from "./buildBatchProtocol";
30
35
  import { BuildBatchRunner } from "./buildBatchRunner";
31
36
  import { BuilderChannel } from "./builderChannel";
37
+ import { type BatchJob, BuilderWorkQueue, type DiscoveryJob } from "./builderWorkQueue";
32
38
  import { prepareDevWatchBatch } from "./devWatchBatch";
33
39
 
34
40
  interface IncrementalBuilderOptions {
@@ -41,6 +47,7 @@ interface IncrementalBuilderOptions {
41
47
  }
42
48
 
43
49
  type IncrementalBuilderBootDeps = Pick<IncrementalBuilderOptions, "artifact" | "optimizedFonts" | "discovery">;
50
+ type BatchWork = Pick<BuildBatchRequest, "generation" | "needs" | "changedFiles" | "trace">;
44
51
 
45
52
  class IncrementalBuilder {
46
53
  #logger = new Logger("IncrementalBuilder");
@@ -56,8 +63,43 @@ class IncrementalBuilder {
56
63
  #autoImportSync: AutoImportSync;
57
64
  #watcher: HmrWatcher | null = null;
58
65
  #generation = 0;
66
+ #discoveredThrough = 0;
59
67
  #csrActive = IncrementalBuilder.#csrArmedByEnv();
60
- #workQueue: Promise<void> = Promise.resolve();
68
+ #csrBundler: CsrDevBundler;
69
+ /** Null when `AKAN_DEV_CSR_PATCHER=off`: every CSR save then goes to a build worker, as before the patcher. */
70
+ #patcher: CsrDevPatcher | null;
71
+ /** A worker's full CSR build (arming, re-arming) that a patch must not race for the csr-dev directory. */
72
+ #csrGate: Promise<void> = Promise.resolve();
73
+ #csrArming = false;
74
+ #csrArms = 0;
75
+ #crashRetryTimer: ReturnType<typeof setTimeout> | null = null;
76
+ readonly #crashRetryEntries = new Set<string>();
77
+ #csrWasActive = false;
78
+ #csrArmSucceeded = false;
79
+ /** Where SSR pages load their client code from in dev; route builds bundle only its server half. */
80
+ #ssrBundler: SsrDevBundler;
81
+ /** Null when `AKAN_DEV_CSR_PATCHER=off`: every registry save then goes to a build worker. */
82
+ #ssrPatcher: CsrDevPatcher | null;
83
+ //* Serializes everything that writes ssr-dev: a save's patch (fast lane), a route build adding the entries it names,
84
+ //* a queued worker batch with the ssr need, and the boot build. Never held while awaiting the slow lane.
85
+ #ssrLock: Promise<void> = Promise.resolve();
86
+ /** How often each reason sent a registry save to a build worker this session: the resident patcher's miss rate. */
87
+ readonly #delegations = new Map<string, number>();
88
+ /** The boot build of the SSR registry while it runs; it holds every entry the routes reach once it lands. */
89
+ #ssrArming: Promise<void> | null = null;
90
+ //* A registry build that failed on the user's code fails again until the code changes, so route builds do not retry
91
+ //* it; the next attempt that touches the registry (a save's patch, a worker batch) settles it. A worker that died
92
+ //* before reporting (an OOM kill) route builds do retry, at most every `#crashRetryMs`.
93
+ #ssrBroken = false;
94
+ #ssrCrashedAt: number | null = null;
95
+ static readonly #crashRetryMs = 10_000;
96
+ /** Arming and the route entries deferred behind it run beside both lanes; a recycle waits for them too. */
97
+ readonly #beside = new Set<Promise<void>>();
98
+ #barrelFailed = false;
99
+ //* Two lanes: a save's codegen and CSR patch in the fast one, which the watcher waits for; build workers, route
100
+ //* builds and discovery in the slow one, which folds queued batches. A save no longer waits behind pages and css.
101
+ #fastQueue: Promise<void> = Promise.resolve();
102
+ #workQueue: BuilderWorkQueue;
61
103
  #inFlight = 0;
62
104
  #workCount = 0;
63
105
  #shuttingDown = false;
@@ -71,6 +113,7 @@ class IncrementalBuilder {
71
113
  this.#optimizedFonts = options.optimizedFonts;
72
114
  this.#discovery = options.discovery;
73
115
  this.#generation = options.initialGeneration ?? 0;
116
+ this.#discoveredThrough = this.#generation;
74
117
  this.#batchRunner = new BuildBatchRunner({
75
118
  workspaceRoot: options.app.workspace.workspaceRoot,
76
119
  cwd: options.app.cwdPath,
@@ -78,34 +121,51 @@ class IncrementalBuilder {
78
121
  this.#changePlanner = new DevChangePlanner({ workspaceRoot: options.app.workspace.workspaceRoot });
79
122
  this.#generatedIndexSync = new DevGeneratedIndexSync({ workspaceRoot: options.app.workspace.workspaceRoot });
80
123
  this.#autoImportSync = new AutoImportSync({ workspaceRoot: options.app.workspace.workspaceRoot });
124
+ this.#csrBundler = new CsrDevBundler(options.app);
125
+ this.#patcher =
126
+ process.env.AKAN_DEV_CSR_PATCHER === "off" ? null : new CsrDevPatcher(this.#csrBundler, { resident: true });
127
+ this.#ssrBundler = new SsrDevBundler(options.app, { discovery: () => this.#discovery });
128
+ this.#ssrPatcher =
129
+ process.env.AKAN_DEV_CSR_PATCHER === "off" ? null : new CsrDevPatcher(this.#ssrBundler, { resident: true });
130
+ this.#workQueue = new BuilderWorkQueue({
131
+ runBatch: async (batch) => await this.#runQueuedBatch(batch),
132
+ onSettled: (label, ms) => {
133
+ this.#workCount += 1;
134
+ this.#logger.verbose(`[work-queue] ${label} finished in ${ms}ms`);
135
+ this.#reportMetrics();
136
+ },
137
+ });
81
138
  }
82
139
 
83
140
  get #artifactDir() {
84
141
  return `${this.#app.cwdPath}/.akan/artifact`;
85
142
  }
86
143
 
87
- /**
88
- * Build a route and answer it. The reply is part of the work item on purpose: `shutdown` drains the
89
- * work queue before exiting, so folding the flush in here is what makes "drained" mean "answered".
90
- */
144
+ // The reply is part of the work item on purpose: `shutdown` drains the queue, so "drained" must mean "answered".
91
145
  async handleBuildRoute(msg: BuilderReq): Promise<void> {
92
- await this.#enqueueWork(`build-route:${msg.routeId}`, async () =>
146
+ await this.#workQueue.enqueue(`build-route:${msg.routeId}`, async () =>
93
147
  BuilderChannel.send(await this.#handleBuildRoute(msg)),
94
148
  );
95
149
  }
96
150
 
97
151
  async #handleBuildRoute(msg: BuilderReq): Promise<BuilderRes> {
152
+ const seenGeneration = this.#discoveredThrough;
98
153
  try {
99
154
  const delta = await new RouteClientBuilder({
100
155
  app: this.#app,
101
156
  routeId: msg.routeId,
102
157
  seeds: msg.seeds,
158
+ graphSeeds: msg.graphSeeds,
103
159
  artifact: this.#artifact,
104
160
  knownEntries: new Set<string>(msg.knownEntries),
105
161
  discovery: this.#discovery,
162
+ browser: "registry",
106
163
  }).build();
164
+ await this.#ensureSsrEntries(delta.registryEntries ?? []);
107
165
  this.#logger.verbose(`build-route ok routeId=${msg.routeId} newEntries=${delta.newEntries.length}`);
108
- this.#sendBuildStatus("route", { generation: msg.generation, ok: true, files: msg.seeds });
166
+ //? The builder's generation, not the route cache's: the cache's does not move when a fixed file maps to no entry,
167
+ //? so the rebuild's ok would carry the failure's own number.
168
+ this.#sendBuildStatus("route", { generation: this.#generation, ok: true, files: msg.seeds, scope: msg.routeId });
109
169
  return {
110
170
  type: "build-route-res",
111
171
  id: msg.id,
@@ -119,56 +179,70 @@ class IncrementalBuilder {
119
179
  clientDepsByEntry: delta.clientDepsByEntry,
120
180
  routeId: msg.routeId,
121
181
  generation: msg.generation,
182
+ seenGeneration,
122
183
  } as BuildRouteResultPayload,
123
184
  };
124
185
  } catch (err) {
125
- const errMsg = err instanceof Error ? err.message : String(err);
186
+ const errMsg = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
126
187
  this.#logger.error(`build-route failed routeId=${msg.routeId}: ${errMsg}`);
127
- this.#sendBuildStatus("route", { generation: msg.generation, ok: false, files: msg.seeds, message: errMsg });
188
+ this.#sendBuildStatus("route", {
189
+ generation: this.#generation,
190
+ ok: false,
191
+ files: msg.seeds,
192
+ message: errMsg,
193
+ scope: msg.routeId,
194
+ });
128
195
  return { type: "build-route-res", id: msg.id, ok: false, error: errMsg };
129
196
  }
130
197
  }
131
198
  #sendBuildStatus(
132
199
  phase: BuildPhase,
133
- { generation, ok, files, message }: { generation?: number; ok: boolean; files?: string[]; message?: string },
200
+ {
201
+ generation,
202
+ ok,
203
+ files,
204
+ message,
205
+ scope,
206
+ }: { generation?: number; ok: boolean; files?: string[]; message?: string; scope?: string },
134
207
  ): void {
135
208
  if (typeof generation !== "number") return;
136
209
  BuilderChannel.emit({
137
210
  type: "build-status",
138
- data: {
139
- generation,
140
- phase,
141
- ok,
142
- files: files ?? [],
143
- message,
144
- },
211
+ data: { generation, phase, ok, files: files ?? [], message, ...(scope !== undefined ? { scope } : {}) },
145
212
  });
146
213
  }
147
- async #enqueueWork<T>(label: string, fn: () => Promise<T>): Promise<T> {
214
+ async #enqueueFast<T>(label: string, fn: () => Promise<T>): Promise<T> {
148
215
  const started = Date.now();
149
216
  this.#inFlight += 1;
150
- const run = this.#workQueue.then(fn, fn);
151
- this.#workQueue = run.then(() => undefined).catch(() => undefined);
217
+ const run = this.#fastQueue.then(fn, fn);
218
+ this.#fastQueue = run.then(() => undefined).catch(() => undefined);
152
219
  try {
153
220
  return await run;
154
221
  } finally {
155
222
  this.#inFlight -= 1;
156
223
  this.#workCount += 1;
157
- this.#logger.verbose(`[work-queue] ${label} finished in ${Date.now() - started}ms`);
224
+ this.#logger.verbose(`[fast-queue] ${label} finished in ${Date.now() - started}ms`);
158
225
  this.#reportMetrics();
159
226
  }
160
227
  }
161
228
 
162
- /** No queued work and no debounced css rebuild, so nothing is lost if the process exits now. */
163
229
  get #idle(): boolean {
164
- return this.#inFlight === 0 && this.#cssRebuildTimer === null;
230
+ return this.#inFlight === 0 && this.#workQueue.size === 0 && this.#cssRebuildTimer === null;
165
231
  }
166
232
 
167
- /**
168
- * Reported only when the builder is idle. The host's only lever against the bundler arenas
169
- * `Bun.build` retains is to recycle this process, and a recycle decided while work is queued would
170
- * either truncate that work or race the shutdown drain — so a busy builder simply says nothing.
171
- */
233
+ // Callers catch their own errors: the promise handed in never rejects.
234
+ #runBeside(work: Promise<void>): Promise<void> {
235
+ this.#inFlight += 1;
236
+ const tracked: Promise<void> = work.finally(() => {
237
+ this.#inFlight -= 1;
238
+ this.#beside.delete(tracked);
239
+ this.#reportMetrics();
240
+ });
241
+ this.#beside.add(tracked);
242
+ return tracked;
243
+ }
244
+
245
+ // Idle only: the host recycles on these metrics, and a recycle decided mid-work would truncate it or race the drain.
172
246
  #reportMetrics(): void {
173
247
  if (!this.#idle || this.#shuttingDown) return;
174
248
  BuilderChannel.emit({
@@ -177,34 +251,31 @@ class IncrementalBuilder {
177
251
  });
178
252
  }
179
253
 
180
- /**
181
- * Finish queued work, then exit so the OS reclaims the bundler arenas. The host restarts a
182
- * replacement; build requests that arrive during the drain are refused with the same retry error
183
- * the backend already handles for any other builder restart.
184
- */
254
+ // Exits so the OS reclaims the bundler arenas; the host spawns the replacement.
185
255
  async shutdown(reason: string): Promise<void> {
186
256
  if (this.#shuttingDown) return;
187
257
  this.#shuttingDown = true;
188
258
  const started = Date.now();
189
259
  this.#logger.debug(`shutdown requested (${reason}); draining ${this.#inFlight} work item(s)`);
190
- // Stopped before the drain, or a save landing mid-drain would enqueue a batch behind the queue tail
191
- // this method already awaited — and `process.exit(0)` would cut that batch off partway through
192
- // writing its artifacts. The replacement rebuilds every artifact from its boot build anyway, so the
193
- // batch is not lost; a half-written one would be.
260
+ // Before the drain: a save landing mid-drain would queue behind the awaited tail and `process.exit` would cut
261
+ // its artifacts off half-written; the replacement's boot build redoes that work anyway.
194
262
  this.#watcher?.stop();
195
263
  if (this.#cssRebuildTimer) {
196
- // Only reachable if a css batch landed between the idle report and this request: the fresh
197
- // boot build recompiles css from scratch anyway, so dropping the debounce loses nothing.
264
+ // Only if css landed after the idle report; the replacement's boot build recompiles css, so nothing is lost.
198
265
  clearTimeout(this.#cssRebuildTimer);
199
266
  this.#cssRebuildTimer = null;
200
267
  this.#pendingCssRebuild = null;
201
268
  }
202
- await this.#workQueue.catch(() => undefined);
269
+ // The fast lane first: a save it is still handling may queue a batch in the slow one.
270
+ await this.#fastQueue.catch(() => undefined);
271
+ await this.#workQueue.drain();
203
272
  await this.#cssRebuildQueue.catch(() => undefined);
204
- // Drained queues do not mean the host has the results. The events those work items produced are the
205
- // largest messages this process sends, and `process.exit` discards an ipc write that has not
206
- // flushed — a `css-updated` relayed milliseconds before this line would be dropped with no error
207
- // anywhere, leaving the backend serving the previous bundle. See `BuilderChannel`.
273
+ while (this.#beside.size > 0) await Promise.all([...this.#beside]);
274
+ if (this.#delegations.size > 0)
275
+ this.#logger.verbose(
276
+ `registry saves handed to build workers this session: ${[...this.#delegations].map(([reason, count]) => `${reason}=${count}`).join(", ")}`,
277
+ );
278
+ // Drained queues are not delivered results: `process.exit` drops unflushed ipc writes (a relayed `css-updated`).
208
279
  const flushed = await BuilderChannel.drain();
209
280
  this.#logger.debug(
210
281
  `drained in ${Date.now() - started}ms${flushed ? ` after flushing ${flushed} ipc write(s)` : ""}; exiting for recycle`,
@@ -215,8 +286,7 @@ class IncrementalBuilder {
215
286
  get shuttingDown(): boolean {
216
287
  return this.#shuttingDown;
217
288
  }
218
- //* Watch events name the file's real path, so synced lib pages are matched by `realDir` while their
219
- //* page key still carries the app-relative `(libs)/(<lib>)` prefix.
289
+ //* Watch events carry real paths: synced lib pages match by `realDir`, their keys keep the `(libs)/(<lib>)` prefix.
220
290
  static #matchPageRoot(roots: PageRoot[], abs: string): PageRoot | null {
221
291
  for (const root of roots) {
222
292
  const absRoot = path.resolve(root.realDir);
@@ -244,7 +314,6 @@ class IncrementalBuilder {
244
314
  }
245
315
  return false;
246
316
  }
247
- /** Debounced css-only rebuild; the compile itself runs in a disposable worker like every other build. */
248
317
  scheduleCssRebuild({ generation, changedFiles }: { generation?: number; changedFiles?: string[] }) {
249
318
  this.#pendingCssRebuild = { generation, changedFiles };
250
319
  if (this.#cssRebuildTimer) clearTimeout(this.#cssRebuildTimer);
@@ -279,13 +348,12 @@ class IncrementalBuilder {
279
348
  }, 150);
280
349
  }
281
350
  async installWatcher() {
282
- const artifactDir = this.#artifactDir;
283
351
  const roots = await new WatchRootResolver(this.#app).resolve();
284
352
  const watcher = new HmrWatcher({
285
353
  roots,
286
354
  logger: this.#logger,
287
355
  onBatch: async (batch: ChangeBatch) => {
288
- await this.#enqueueWork("hmr-batch", async () => this.#handleWatchBatch(artifactDir, batch));
356
+ await this.#enqueueFast("hmr-batch", async () => this.#handleWatchBatch(batch));
289
357
  },
290
358
  });
291
359
  await watcher.start();
@@ -293,13 +361,12 @@ class IncrementalBuilder {
293
361
  this.#logger.verbose(`watching ${roots.length} roots`);
294
362
  }
295
363
 
296
- async #handleWatchBatch(artifactDir: string, batch: ChangeBatch) {
364
+ async #handleWatchBatch(batch: ChangeBatch) {
297
365
  const rawKinds = new Set(batch.kinds);
298
366
  if (rawKinds.size === 0) return;
299
367
  const generation = ++this.#generation;
300
- //* Insert framework imports that are used but omitted (e.g. `Int` in *.constant.ts, `fetch` in
301
- //* *.store.ts) before regenerating barrels. Edits land on files already in this batch, so they
302
- //* rebuild in this same generation; the write is idempotent so it does not re-trigger the watcher.
368
+ const trace = { ...batch.trace, batchAt: Date.now() };
369
+ //* Auto-import edits touch only this batch's files, so they rebuild in this same generation.
303
370
  const [autoImport, indexSync] = await CodegenLock.run(
304
371
  this.#app.workspace.workspaceRoot,
305
372
  `hmr-batch:${this.#app.name}`,
@@ -311,9 +378,7 @@ class IncrementalBuilder {
311
378
  for (const error of autoImport.errors) this.#logger.error(error);
312
379
  if (autoImport.changedFiles.length > 0)
313
380
  this.#logger.verbose(`[auto-import] inserted imports into ${autoImport.changedFiles.length} file(s)`);
314
- //* Both passes above write source files, and this generation's build consumes what they wrote. Hand
315
- //* them to the watcher so its verification scan does not read them back as a user edit and spend a
316
- //* second generation rebuilding identical content.
381
+ //* Absorbed so the watcher's verification scan does not replay these writes as a user edit next generation.
317
382
  await this.#watcher?.absorb([...autoImport.changedFiles, ...indexSync.changedFiles]);
318
383
  const { files, kinds, expandedBatch, devPlan, event, hasSyncErrors } = prepareDevWatchBatch({
319
384
  generation,
@@ -326,24 +391,26 @@ class IncrementalBuilder {
326
391
  );
327
392
  for (const error of indexSync.errors) this.#logger.error(error);
328
393
 
329
- if (kinds.includes("code")) {
330
- const started = Date.now();
331
- if (kinds.includes("config")) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
332
- else this.#discovery.invalidate?.(files);
333
- this.#logger.verbose(
334
- `client-entry-discovery ${kinds.includes("config") ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`,
335
- );
336
- }
394
+ // Invalidated in the slow lane, under the route builds that read it; a registry check reading it beside them caches
395
+ // nothing a walk began before the invalidation (the discovery's epoch).
396
+ const discovery = kinds.includes("code") ? { files, refresh: kinds.includes("config"), generation } : undefined;
337
397
 
338
398
  if (hasSyncErrors) {
399
+ this.#barrelFailed = true;
339
400
  this.#sendBuildStatus("barrel", { generation, ok: false, files, message: indexSync.errors.join("\n") });
340
401
  BuilderChannel.emit(event);
402
+ if (discovery)
403
+ void this.#workQueue
404
+ .enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
405
+ .catch(this.#slowLaneFailed("discovery"));
341
406
  return;
342
407
  }
343
- if (indexSync.changedFiles.length > 0) this.#sendBuildStatus("barrel", { generation, ok: true, files });
408
+ // After a failure too, where nothing changed on disk: the host re-sends a phase still failing to every new tab.
409
+ if (indexSync.changedFiles.length > 0 || this.#barrelFailed)
410
+ this.#sendBuildStatus("barrel", { generation, ok: true, files });
411
+ this.#barrelFailed = false;
344
412
 
345
- // Server-only generations (e.g. a .service.ts or srvkit edit) must not rebuild or refresh the
346
- // client: a fresh pages buildId would broadcast rsc-refresh to browsers for no visible change.
413
+ // Server-only generations skip the client: a fresh pages buildId would rsc-refresh browsers for no visible change.
347
414
  const rebuildClient = devPlan.actions.includes("rebuild-client");
348
415
  if (kinds.includes("code") && !rebuildClient) {
349
416
  this.#logger.verbose(`client rebuild skipped; devPlan actions=${devPlan.actions.join(",") || "(none)"}`);
@@ -358,75 +425,392 @@ class IncrementalBuilder {
358
425
  this.#logger.verbose("pageKeys refresh skipped; changed page source cannot add/remove a route key");
359
426
  }
360
427
 
361
- const needs: BuildBatchNeed[] = [];
428
+ BuilderChannel.emit(event);
429
+
362
430
  if (kinds.includes("code") && rebuildClient) {
363
- if (this.#shouldRebuildCsr()) needs.push("csr");
364
- else
431
+ // Css rides the pages batch rather than its own debounce: the slow lane folds a burst of saves into one batch.
432
+ const needs: BuildBatchNeed[] = ["pages", "css"];
433
+ if (!this.#csrActive)
365
434
  this.#logger.verbose(
366
435
  `csr-rebundle skipped; request /__csr or ?csr=true (or set AKAN_DEV_CSR_REBUILD=1) to enable per-save CSR rebuilds`,
367
436
  );
368
- needs.push("pages");
369
- // Server-only code edits cannot introduce class names the CSS scanner would pick up; only a
370
- // client rebuild or a direct stylesheet edit can change the compiled CSS. Folded into this
371
- // generation's batch rather than debounced separately: the work queue already serializes
372
- // generations, so by the time a second batch runs the 150ms debounce would have fired anyway,
373
- // and a second worker spawn per save costs more than the coalescing ever saved.
374
- needs.push("css");
437
+ //? One after the other, since two compiles side by side slow both. CSR goes first unless its build is being armed:
438
+ //? a CSR patch then waits behind #csrGate, which SSR tabs must not.
439
+ const patchCsr = async () => (this.#csrActive ? await this.#patchCsr(generation, files, trace) : false);
440
+ const patchSsr = async () => await this.#patchSsr(generation, files, trace);
441
+ let csrToWorker: boolean;
442
+ let ssrToWorker: boolean;
443
+ if (this.#csrArming) {
444
+ ssrToWorker = await patchSsr();
445
+ csrToWorker = await patchCsr();
446
+ } else {
447
+ csrToWorker = await patchCsr();
448
+ ssrToWorker = await patchSsr();
449
+ }
450
+ if (csrToWorker) needs.unshift("csr");
451
+ if (ssrToWorker) needs.unshift("ssr");
452
+ const batch: BatchJob = { generation, needs, changedFiles: files, trace, ...(discovery ? { discovery } : {}) };
453
+ // A worker's registry build rewrites the directory its patcher reads, so the next save waits for it.
454
+ if (needs.includes("csr") || needs.includes("ssr")) await this.#workQueue.enqueueBatch(batch);
455
+ else void this.#workQueue.enqueueBatch(batch).catch(this.#slowLaneFailed("batch"));
456
+ return;
375
457
  }
376
-
377
- BuilderChannel.emit(event);
378
-
379
- if (needs.length > 0) await this.#runBatch({ generation, needs, changedFiles: files });
380
- // A css-only batch keeps its debounce: those arrive in bursts while a stylesheet is edited, and
381
- // without a pages build in front of them there is nothing else to space them out.
382
- else if (kinds.includes("css")) {
458
+ if (discovery)
459
+ void this.#workQueue
460
+ .enqueue("discovery", async () => await this.#refreshDiscovery(discovery))
461
+ .catch(this.#slowLaneFailed("discovery"));
462
+ // Css-only batches keep the debounce: they arrive in bursts while a stylesheet is edited.
463
+ if (kinds.includes("css")) {
383
464
  this.scheduleCssRebuild({ generation, changedFiles: files });
384
465
  this.#logger.verbose(`css-rebuild scheduled generation=${generation}`);
385
466
  }
386
467
  }
387
468
 
388
- /**
389
- * Run one generation of build work in a process that exits afterwards. The worker streams the
390
- * messages the backend and the HMR overlay consume, which this relays untouched, so the sequence a
391
- * browser observes is the same one the in-process build produced.
392
- */
393
- async #runBatch({
394
- generation,
395
- needs,
396
- changedFiles,
397
- }: {
398
- generation: number;
399
- needs: BuildBatchNeed[];
400
- changedFiles: string[];
401
- }): Promise<BuildBatchResult> {
469
+ // True when the save has to go to a build worker: the patcher handed it back, or threw (the worker reports why).
470
+ async #patchCsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
471
+ const patcher = this.#patcher;
472
+ if (!patcher) return true;
473
+ await this.#csrGate;
474
+ // The arming build this save waited for failed: CSR is off again, and the next request arms it anew.
475
+ if (!this.#csrActive) return false;
402
476
  const started = Date.now();
403
- const result = await this.#batchRunner.run(await this.#batchRequest({ generation, needs, changedFiles }), (msg) =>
404
- BuilderChannel.emit(msg),
477
+ const marker = path.join(this.#csrBundler.outDir, CSR_DEV_PATCHING_MARKER);
478
+ await Bun.write(marker, String(process.pid));
479
+ try {
480
+ const result = await patcher.update(files, {
481
+ announce: (update) => {
482
+ const now = Date.now();
483
+ BuilderChannel.emit({
484
+ type: "csr-updated",
485
+ data: {
486
+ generation: update.generation,
487
+ mode: "registry",
488
+ reload: update.reload,
489
+ reason: update.reason,
490
+ patchUrl: update.patchUrl,
491
+ changedIds: update.changedIds,
492
+ trace: { ...trace, patchAt: now, sentAt: now },
493
+ },
494
+ });
495
+ },
496
+ });
497
+ if (result.kind === "delegate") {
498
+ this.#logger.verbose(
499
+ `csr-patch handed to a build worker (${this.#countDelegation("csr", result.reason)}): ${result.reason}`,
500
+ );
501
+ return true;
502
+ }
503
+ this.#sendBuildStatus("csr", { generation, ok: true, files });
504
+ this.#logger.verbose(
505
+ result.kind === "unchanged"
506
+ ? `csr-patch unchanged (${Date.now() - started}ms)`
507
+ : `csr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
508
+ );
509
+ return false;
510
+ } catch (err) {
511
+ const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
512
+ if (IncrementalBuilder.#isCompileError(err)) {
513
+ this.#sendBuildStatus("csr", { generation, ok: false, files, message });
514
+ return false;
515
+ }
516
+ this.#logger.verbose(`csr-patch threw; a build worker takes this save: ${message}`);
517
+ patcher.forget();
518
+ return true;
519
+ } finally {
520
+ await rm(marker, { force: true });
521
+ }
522
+ }
523
+
524
+ // True when the save has to go to a build worker, as for CSR.
525
+ async #patchSsr(generation: number, files: string[], trace: HmrTrace): Promise<boolean> {
526
+ const patcher = this.#ssrPatcher;
527
+ if (!patcher) return true;
528
+ const hold = await ServerGraphFile.touches(await ServerGraphFile.read(this.#artifactDir), files, (file) =>
529
+ ServerGraphFile.clientExportsOf(file),
530
+ );
531
+ //? Ahead of this save's own discovery job in the slow lane: which client entries a page still names decides which
532
+ //? pending roots this patch keeps.
533
+ this.#discovery.invalidate?.(files);
534
+ return await this.#withSsrLock(
535
+ async () => await this.#runSsrPatcher(patcher, files, { trace, hold, batchGeneration: generation }),
536
+ );
537
+ }
538
+
539
+ //* Adds the entries a route build names to the registry, since the tab requires them by id. A registry that cannot
540
+ //* take them fails no route build: the page answers, and its tab waits for the registry while the overlay says why.
541
+ async #ensureSsrEntries(entries: string[], { crashRetry = false }: { crashRetry?: boolean } = {}): Promise<void> {
542
+ if (entries.length === 0) return;
543
+ //? The boot build takes every entry the routes reach, so the first page answers now and its tab waits for the
544
+ //? registry (boot.json) instead; whatever that build missed is added right after it lands.
545
+ const arming = this.#ssrArming;
546
+ if (arming) {
547
+ void this.#runBeside(
548
+ arming
549
+ .then(async () => await this.#ensureSsrEntries(entries, { crashRetry }))
550
+ .catch(this.#slowLaneFailed("ssr-ensure")),
551
+ );
552
+ return;
553
+ }
554
+ await this.#withSsrLock(async () => {
555
+ const patcher = this.#ssrPatcher;
556
+ // The builder's generation, not the request's: its build-status joins the saves' `ssr` ones.
557
+ const generation = this.#generation;
558
+ if (
559
+ patcher &&
560
+ !(await this.#runSsrPatcher(patcher, [], { roots: entries, onlyRoots: true, batchGeneration: generation }))
561
+ )
562
+ return;
563
+ //? Read, not compiled: with the patcher off, the save's own worker batch already built every entry.
564
+ if (!patcher && (await this.#ssrBundler.holds(entries))) return;
565
+ //? Not retried per route build: the save that fixes the error rebuilds it, where each route build retrying held
566
+ //? the SSR lock that save's patch waits on.
567
+ if (this.#ssrBroken) return;
568
+ if (this.#ssrCrashedAt !== null && Date.now() - this.#ssrCrashedAt < IncrementalBuilder.#crashRetryMs) {
569
+ //? Handed over just now with nothing run for them: the retry must find them to hand over again.
570
+ patcher?.releaseHandedOver();
571
+ this.#retryAfterCrash(this.#ssrCrashedAt, entries);
572
+ return;
573
+ }
574
+ await this.#runSsrWorker(generation).catch((err: unknown) => {
575
+ this.#logger.error(
576
+ `ssr-registry could not take a route's entries; the save that fixes it rebuilds it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
577
+ );
578
+ });
579
+ //? A worker that died took these entries with it and left them pending nowhere: they are tried once more after the
580
+ //? crash window, but a retry that crashes again waits for the next route build or save.
581
+ if (this.#ssrCrashedAt !== null && !crashRetry) this.#retryAfterCrash(this.#ssrCrashedAt, entries);
582
+ });
583
+ }
584
+
585
+ //? Once the crash window closes, for the entries skipped in it: later route builds may name none (the first one
586
+ //? announced every entry to the route cache), so without this a tab could wait on boot.json until the next save.
587
+ #retryAfterCrash(crashedAt: number, entries: string[]): void {
588
+ for (const entry of entries) this.#crashRetryEntries.add(entry);
589
+ if (this.#crashRetryTimer) return;
590
+ const timer = setTimeout(
591
+ () => {
592
+ this.#crashRetryTimer = null;
593
+ const retried = [...this.#crashRetryEntries];
594
+ this.#crashRetryEntries.clear();
595
+ if (this.shuttingDown || retried.length === 0) return;
596
+ void this.#runBeside(this.#ensureSsrEntries(retried, { crashRetry: true })).catch(
597
+ this.#slowLaneFailed("ssr-crash-retry"),
598
+ );
599
+ },
600
+ Math.max(0, crashedAt + IncrementalBuilder.#crashRetryMs - Date.now()),
601
+ );
602
+ timer.unref?.();
603
+ this.#crashRetryTimer = timer;
604
+ }
605
+
606
+ //* The boot build of the SSR registry runs beside the slow lane, holding only the SSR lock: the first page's route
607
+ //* build bundles meanwhile instead of queueing behind it (on apps/akan it cost the first page about a second).
608
+ async armSsrRegistry(): Promise<void> {
609
+ //? `AKAN_DEV_SSR_ARM_DELAY_MS` is a test hook: an E2E has a tab reconnect before the boot build has run.
610
+ const delayMs = Number(process.env.AKAN_DEV_SSR_ARM_DELAY_MS);
611
+ if (Number.isInteger(delayMs) && delayMs > 0) await Bun.sleep(delayMs);
612
+ const arm = async () =>
613
+ await this.#withSsrLock(async () => {
614
+ const generation = this.#generation;
615
+ if (this.#ssrPatcher && !(await this.#runSsrPatcher(this.#ssrPatcher, [], { batchGeneration: generation })))
616
+ return;
617
+ await this.#runSsrWorker(generation);
618
+ }).catch((err: unknown) => {
619
+ this.#logger.error(
620
+ `ssr-registry boot build failed; the next save retries it: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
621
+ );
622
+ });
623
+ const arming = this.#runBeside(
624
+ (async () => {
625
+ await arm();
626
+ //? A boot build whose worker was killed (a small container's OOM) is tried once more before `boot-armed` lets
627
+ //? the next app boot: retried after that, the two biggest workers of a boot would run side by side.
628
+ const crashedAt = this.#ssrCrashedAt;
629
+ if (crashedAt === null) return;
630
+ while (!this.shuttingDown && Date.now() < crashedAt + IncrementalBuilder.#crashRetryMs) await Bun.sleep(250);
631
+ if (!this.shuttingDown) await arm();
632
+ })(),
633
+ );
634
+ this.#ssrArming = arming;
635
+ await arming;
636
+ this.#ssrArming = null;
637
+ }
638
+
639
+ //? A save the user broke fails the same way in a worker, so it is reported here and the patcher keeps its state: the
640
+ //? modules it could not compile stay pending in the graph and the fixing save compiles them.
641
+ static #isCompileError(error: unknown): boolean {
642
+ return error instanceof AggregateError;
643
+ }
644
+
645
+ // Voided so the fast lane need not wait; unhandled, a rejection would exit the builder and drop the watcher with it.
646
+ #slowLaneFailed(label: string) {
647
+ return (error: unknown) =>
648
+ this.#logger.error(
649
+ `${label} failed in the slow lane: ${ApplicationBuildReporter.formatError(error, this.#app.workspace.workspaceRoot)}`,
650
+ );
651
+ }
652
+
653
+ // A reason's own detail (which import) follows its first colon, so saves of one kind count together.
654
+ #countDelegation(registry: "csr" | "ssr", reason: string): string {
655
+ const key = `${registry}:${reason.split(":")[0]}`;
656
+ const count = (this.#delegations.get(key) ?? 0) + 1;
657
+ this.#delegations.set(key, count);
658
+ return `#${count} for this reason`;
659
+ }
660
+
661
+ async #withSsrLock<T>(fn: () => Promise<T>): Promise<T> {
662
+ const run = this.#ssrLock.then(fn, fn);
663
+ this.#ssrLock = run.then(
664
+ () => undefined,
665
+ () => undefined,
405
666
  );
667
+ return await run;
668
+ }
669
+
670
+ // Runs the worker directly, under the SSR lock its callers hold: queueing it could wait on a batch that waits on them.
671
+ async #runSsrWorker(generation: number): Promise<void> {
672
+ let result: BuildBatchResult;
673
+ try {
674
+ result = await this.#runBatch({ generation, needs: ["ssr"], changedFiles: [] });
675
+ } finally {
676
+ this.#ssrPatcher?.forget();
677
+ }
678
+ this.#noteSsrWorker(result);
679
+ if (result.errors.ssr) throw new Error(result.errors.ssr);
680
+ }
681
+
682
+ #noteSsrWorker(result: BuildBatchResult): void {
683
+ const crashed = !!result.crashed && (result.crashedNeeds ?? ["ssr"]).includes("ssr");
684
+ this.#ssrBroken = !!result.errors.ssr && !crashed;
685
+ this.#ssrCrashedAt = crashed ? Date.now() : null;
686
+ }
687
+
688
+ async #runSsrPatcher(
689
+ patcher: CsrDevPatcher,
690
+ files: string[],
691
+ {
692
+ roots,
693
+ onlyRoots,
694
+ trace,
695
+ hold,
696
+ batchGeneration,
697
+ }: { roots?: string[]; onlyRoots?: boolean; trace?: HmrTrace; hold?: boolean; batchGeneration?: number } = {},
698
+ ): Promise<boolean> {
699
+ const started = Date.now();
700
+ const marker = path.join(this.#ssrBundler.outDir, CSR_DEV_PATCHING_MARKER);
701
+ await Bun.write(marker, String(process.pid));
702
+ try {
703
+ const result = await patcher.update(files, {
704
+ roots,
705
+ onlyRoots,
706
+ announce: (update) => {
707
+ const now = Date.now();
708
+ BuilderChannel.emit({
709
+ type: "ssr-updated",
710
+ data: {
711
+ generation: update.generation,
712
+ reload: update.reload,
713
+ reason: update.reason,
714
+ patchUrl: update.patchUrl,
715
+ changedIds: update.changedIds,
716
+ ...(trace ? { trace: { ...trace, patchAt: now, sentAt: now } } : {}),
717
+ ...(hold ? { hold } : {}),
718
+ ...(batchGeneration !== undefined ? { batchGeneration } : {}),
719
+ ...(update.epoch !== undefined ? { epoch: update.epoch } : {}),
720
+ ...(update.first ? { first: true } : {}),
721
+ },
722
+ });
723
+ },
724
+ });
725
+ if (result.kind === "delegate") {
726
+ this.#logger.verbose(
727
+ `ssr-patch handed to a build worker (${this.#countDelegation("ssr", result.reason)}): ${result.reason}`,
728
+ );
729
+ return true;
730
+ }
731
+ //? A route build's check reports only a failure: it compiles the roots the registry lacks and nothing else, so
732
+ //? its ok, of the builder's current generation, would read as the fix of a save that broke something else.
733
+ if (!onlyRoots) {
734
+ this.#ssrBroken = false;
735
+ this.#ssrCrashedAt = null;
736
+ this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: true, files });
737
+ }
738
+ this.#logger.verbose(
739
+ result.kind === "unchanged"
740
+ ? `ssr-patch unchanged (${Date.now() - started}ms)`
741
+ : `ssr-patch generation=${result.update.generation} ${result.update.reload ? `reload (${result.update.reason})` : `patch modules=${result.update.changedIds.length}`} (${Date.now() - started}ms)`,
742
+ );
743
+ return false;
744
+ } catch (err) {
745
+ const message = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
746
+ if (IncrementalBuilder.#isCompileError(err)) {
747
+ this.#sendBuildStatus("ssr", { generation: batchGeneration, ok: false, files, message });
748
+ return false;
749
+ }
750
+ this.#logger.verbose(`ssr-patch threw; a build worker takes it: ${message}`);
751
+ patcher.forget();
752
+ return true;
753
+ } finally {
754
+ await rm(marker, { force: true });
755
+ }
756
+ }
757
+
758
+ async #runQueuedBatch({ discovery, ...work }: BatchJob): Promise<void> {
759
+ if (discovery) await this.#refreshDiscovery(discovery);
760
+ //? The slow lane runs in order, so every batch up to this one has had its discovery job.
761
+ this.#discoveredThrough = Math.max(this.#discoveredThrough, work.generation);
762
+ // A worker writing ssr-dev must not overlap the boot build, which no longer runs in this lane.
763
+ //? Forgotten inside the lock: a route build's check waiting on it would patch the old state over the worker's.
764
+ try {
765
+ if (work.needs.includes("ssr"))
766
+ await this.#withSsrLock(async () => {
767
+ try {
768
+ this.#noteSsrWorker(await this.#runBatch(work));
769
+ } finally {
770
+ this.#ssrPatcher?.forget();
771
+ }
772
+ });
773
+ else await this.#runBatch(work);
774
+ } finally {
775
+ if (work.needs.includes("csr")) this.#patcher?.forget();
776
+ }
777
+ }
778
+
779
+ async #refreshDiscovery({ files, refresh, generation }: DiscoveryJob): Promise<void> {
780
+ const started = Date.now();
781
+ if (refresh) this.#discovery = await GraphClientEntryDiscovery.create(this.#app);
782
+ else this.#discovery.invalidate?.(files);
783
+ this.#discoveredThrough = Math.max(this.#discoveredThrough, generation);
784
+ this.#logger.verbose(`client-entry-discovery ${refresh ? "refreshed" : "invalidated"} (${Date.now() - started}ms)`);
785
+ }
786
+
787
+ async #runBatch(work: BatchWork): Promise<BuildBatchResult> {
788
+ const { generation, needs, changedFiles } = work;
789
+ const started = Date.now();
790
+ const result = await this.#batchRunner.run(await this.#batchRequest(work), (msg) => BuilderChannel.emit(msg));
406
791
  if (result.optimizedFonts) this.#optimizedFonts = result.optimizedFonts;
407
792
  if (result.cssAssets) this.#artifact = { ...this.#artifact, cssAssets: result.cssAssets };
408
- // A worker that died before reporting streamed no build-status of its own, so report one per need
409
- // it was given: the generation must go red rather than look like it silently succeeded.
793
+ // A need the worker died before reporting goes red here instead of looking silently successful.
410
794
  if (result.crashed) {
411
- // `base` is excluded because it is not a `BuildPhase`: a boot build has no phase board to fail, and
412
- // it never travels through here — `#buildBootDeps` runs it and throws into the degraded-boot path.
413
- for (const need of needs)
795
+ const crashedNeeds = result.crashedNeeds ?? needs;
796
+ //? A worker that died wrote no failure for the roots handed to it: they must not wait on one that never comes.
797
+ if (crashedNeeds.includes("csr")) this.#patcher?.releaseHandedOver();
798
+ if (crashedNeeds.includes("ssr")) this.#ssrPatcher?.releaseHandedOver();
799
+ // `base` is not a `BuildPhase` and never comes through here: `#buildBootDeps` throws into the degraded boot.
800
+ for (const need of crashedNeeds)
414
801
  if (need !== "base")
415
- this.#sendBuildStatus(need, { generation, ok: false, files: changedFiles, message: result.errors[need] });
802
+ this.#sendBuildStatus(need, {
803
+ generation,
804
+ ok: false,
805
+ files: changedFiles,
806
+ message: `${result.errors[need] ?? "the build worker crashed"}; the next save builds it again`,
807
+ });
416
808
  }
417
809
  if (needs.includes("css")) this.#logger.verbose(`css-rebuild checked (${Date.now() - started}ms)`);
418
810
  return result;
419
811
  }
420
812
 
421
- async #batchRequest({
422
- generation,
423
- needs,
424
- changedFiles,
425
- }: {
426
- generation: number;
427
- needs: BuildBatchNeed[];
428
- changedFiles: string[];
429
- }): Promise<BuildBatchRequest> {
813
+ async #batchRequest({ generation, needs, changedFiles, trace }: BatchWork): Promise<BuildBatchRequest> {
430
814
  return {
431
815
  appName: this.#app.name,
432
816
  workspaceRoot: this.#app.workspace.workspaceRoot,
@@ -438,6 +822,7 @@ class IncrementalBuilder {
438
822
  optimizedFonts: this.#optimizedFonts,
439
823
  cssAssets: this.#artifact.cssAssets ?? null,
440
824
  artifactDir: path.resolve(this.#artifactDir),
825
+ ...(trace ? { trace } : {}),
441
826
  };
442
827
  }
443
828
 
@@ -447,32 +832,20 @@ class IncrementalBuilder {
447
832
  this.#logger.verbose(`ready (watch=${this.#watch})`);
448
833
  }
449
834
 
450
- /**
451
- * After a degraded boot recovers, the backend is still serving the last-good bundle; push a
452
- * fresh pages/css state so connected browsers pick up the fixed code without another edit.
453
- */
835
+ // The backend still serves the last-good bundle after a degraded boot; push fresh pages/css without another edit.
454
836
  async announceRecoveredState(changedFiles: string[]): Promise<void> {
455
837
  const generation = ++this.#generation;
456
- await this.#enqueueWork("boot-recovered", async () => {
838
+ await this.#workQueue.enqueue("boot-recovered", async () => {
457
839
  await this.#runBatch({ generation, needs: ["pages", "css"], changedFiles });
458
840
  });
459
841
  }
460
842
 
461
- /**
462
- * Re-announce the state this builder just booted with, after the host recycled the previous one.
463
- *
464
- * The backend reads `base-artifact.json` once at boot and never re-reads it, so a pages or css hash
465
- * that moved while the builder was being replaced would otherwise leave it pointing at the previous
466
- * artifact until the next save. Announced from the boot artifact rather than by rebuilding: the
467
- * bundles are already on disk, and spending another ~200MB of bundler arena in a process that was
468
- * just recycled to reclaim memory would defeat the purpose. The host suppresses the announcement
469
- * when the hashes match, which is the common case, so a clean recycle never reloads a browser.
470
- */
843
+ // The backend reads base-artifact.json once, at its boot. Re-announced from disk rather than rebuilt (this process
844
+ // was recycled to free bundler memory); the host drops it when hashes match, so a clean recycle reloads nothing.
471
845
  async announceBootState(): Promise<void> {
472
846
  const generation = ++this.#generation;
473
847
  const reason = "builder-recycle" as const;
474
- // Awaited rather than emitted: this runs during a recycle, so the host may ask this builder to shut
475
- // down at any moment, and "announced boot state" must mean the announcement left the process.
848
+ // Awaited, not emitted: the host may shut this builder down any moment, so "announced" must mean flushed.
476
849
  await BuilderChannel.send({
477
850
  type: "pages-updated",
478
851
  data: {
@@ -499,51 +872,64 @@ class IncrementalBuilder {
499
872
  this.#logger.verbose(`announced boot state after recycle generation=${generation}`);
500
873
  }
501
874
 
502
- /**
503
- * Build the dev CSR artifact because a request asked for it, and keep it in sync from now on. The
504
- * dev server only serves CSR through the opt-in `/__csr` and `?csr=true` routes — mobile local dev
505
- * points a device WebView at the latter — so nothing needs the artifact until one of them is hit.
506
- */
875
+ // On demand: dev serves CSR only via the opt-in `/__csr` and `?csr=true` routes; once built, it rebuilds every save.
507
876
  async handleBuildCsr(msg: BuilderCsrReq): Promise<void> {
508
- await this.#enqueueWork("build-csr", async (): Promise<void> => {
877
+ //? Armed now, not when the build lands: a save handled meanwhile then patches CSR behind #csrGate instead of skipping.
878
+ this.#beginCsrArm();
879
+ this.#csrActive = true;
880
+ const armed = this.#workQueue.enqueue("build-csr", async (): Promise<void> => {
509
881
  const started = Date.now();
510
- // Messages are not relayed: an on-demand CSR build is a request/response, and the phase board
511
- // never carried a csr status for it before. The error travels in the response below.
512
- const result = await this.#batchRunner.run(
513
- await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
514
- );
515
- const error = result.errors.csr;
882
+ let error: string | undefined;
883
+ try {
884
+ // Not relayed: an on-demand CSR build is request/response, and its error travels in the response.
885
+ const result = await this.#batchRunner.run(
886
+ await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
887
+ );
888
+ error = result.errors.csr;
889
+ } catch (err) {
890
+ error = ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot);
891
+ }
516
892
  if (error) {
517
893
  this.#logger.error(`csr-build failed: ${error}`);
518
894
  await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: false, error });
519
895
  return;
520
896
  }
521
- this.#csrActive = true;
897
+ this.#csrArmSucceeded = true;
898
+ this.#patcher?.forget();
522
899
  this.#logger.info(`csr-build ok on demand (${Date.now() - started}ms); rebuilding CSR on every save now`);
523
900
  await BuilderChannel.send({ type: "build-csr-res", id: msg.id, ok: true });
524
901
  });
902
+ this.#csrGate = this.#armingCsr(armed);
903
+ await armed;
904
+ }
905
+
906
+ //? Settled by the last of the arming builds in flight, not by each: two requests that both fail would otherwise leave
907
+ //? CSR on (the second saw the first's optimistic flag as where it started), and the first to finish would let saves
908
+ //? patch CSR first while the second still builds.
909
+ #beginCsrArm(): void {
910
+ if (this.#csrArms === 0) {
911
+ this.#csrWasActive = this.#csrActive;
912
+ this.#csrArmSucceeded = false;
913
+ }
914
+ this.#csrArms += 1;
915
+ this.#csrArming = true;
525
916
  }
526
917
 
527
- #shouldRebuildCsr() {
528
- return this.#csrActive;
918
+ #armingCsr(armed: Promise<void>): Promise<void> {
919
+ const settle = () => {
920
+ this.#csrArms -= 1;
921
+ if (this.#csrArms > 0) return;
922
+ this.#csrArming = false;
923
+ this.#csrActive = this.#csrWasActive || this.#csrArmSucceeded;
924
+ };
925
+ return armed.then(settle, settle);
529
926
  }
530
927
 
531
928
  static #csrArmedByEnv() {
532
929
  return process.env.AKAN_DEV_CSR_REBUILD === "1";
533
930
  }
534
931
 
535
- /**
536
- * Build the boot artifact in a process that exits afterwards, and keep only the serializable result.
537
- *
538
- * This runs in a worker for the same reason every other build does, and it was the largest single
539
- * holdout: measured on `apps/akan`, `SsrBaseArtifactBuilder.build()` retains **+1143 MB** that
540
- * `Bun.gc(true)` cannot touch, which is 65 % of the builder's post-boot RSS. Nothing was lost by
541
- * moving it — the builder only ever kept `artifact` and `optimizedFonts`, both plain data, and the
542
- * artifact is written to `base-artifact.json` regardless.
543
- *
544
- * `GraphClientEntryDiscovery.create` stays here because route builds need it live, and it costs
545
- * nothing to keep: measured at **0 ms and 0 MB**, because it builds its graph lazily on first use.
546
- */
932
+ // Discovery stays in-process because route builds need it live; it is free at boot, building its graph lazily.
547
933
  static async #buildBootDeps(app: App, runner: BuildBatchRunner): Promise<IncrementalBuilderBootDeps> {
548
934
  const result = await runner.run({
549
935
  appName: app.name,
@@ -552,15 +938,12 @@ class IncrementalBuilder {
552
938
  generation: 0,
553
939
  needs: ["base"],
554
940
  changedFiles: [],
555
- // Discovered by the worker: at boot the watcher has no validated keys to seed, and the boot build
556
- // globs them itself anyway.
557
941
  pageKeys: null,
558
942
  optimizedFonts: null,
559
943
  cssAssets: null,
560
944
  artifactDir: path.resolve(`${app.cwdPath}/.akan/artifact`),
561
945
  });
562
- // A failed boot build has to throw, not degrade quietly: `main` catches this to enter the degraded
563
- // watch mode that keeps the dev server alive until the error is fixed.
946
+ // Throws rather than degrading quietly: `main` catches it to enter degraded watch mode.
564
947
  if (result.errors.base) throw new Error(result.errors.base);
565
948
  if (!result.artifact || !result.optimizedFonts)
566
949
  throw new Error("boot build reported success without an artifact; the build worker likely died");
@@ -568,40 +951,43 @@ class IncrementalBuilder {
568
951
  return { artifact: result.artifact, optimizedFonts: result.optimizedFonts, discovery };
569
952
  }
570
953
 
571
- /**
572
- * A session that already armed dev CSR keeps it armed across builder restarts through the env flag,
573
- * so the artifact has to be rebuilt for the replacement. Queued after `builder-ready` and run in a
574
- * disposable worker rather than inline: a full minified browser-target build of every page costs
575
- * ~350MB of bundler arena that an inline build would never give back, and nothing serves CSR until a
576
- * `/__csr` or `?csr=true` request arrives anyway.
577
- */
954
+ // The env flag carries an armed CSR session across builder restarts, so the replacement rebuilds it after ready.
578
955
  async rearmCsrFromEnv(): Promise<void> {
579
956
  if (!IncrementalBuilder.#csrArmedByEnv()) return;
957
+ this.#beginCsrArm();
580
958
  this.#csrActive = true;
581
- await this.#enqueueWork("build-csr-rearm", async () => {
959
+ //? The env keeps CSR armed whatever this build does, as it did before the builder restarted.
960
+ this.#csrArmSucceeded = true;
961
+ const rearmed = this.#workQueue.enqueue("build-csr-rearm", async () => {
962
+ //? Relayed, unlike an on-demand build: a rebuild for a metadata save must reach the CSR tabs as their reload.
582
963
  const result = await this.#batchRunner.run(
583
964
  await this.#batchRequest({ generation: this.#generation, needs: ["csr"], changedFiles: [] }),
965
+ (msg) => BuilderChannel.emit(msg),
584
966
  );
967
+ this.#patcher?.forget();
585
968
  if (result.errors.csr) this.#logger.error(`csr-rearm failed: ${result.errors.csr}`);
586
969
  else this.#logger.verbose("csr-rearm ok; this session had CSR armed before the builder restarted");
587
970
  });
971
+ this.#csrGate = this.#armingCsr(rearmed);
972
+ await rearmed.catch((err: unknown) =>
973
+ this.#logger.error(
974
+ `csr-rearm failed: ${ApplicationBuildReporter.formatError(err, this.#app.workspace.workspaceRoot)}`,
975
+ ),
976
+ );
588
977
  }
589
978
 
590
- /**
591
- * A compile error in the boot build must not kill the builder: the builder is the dev server's
592
- * file watcher, so exiting here leaves nothing to notice the fix. Report the failure, emit
593
- * builder-ready so the host keeps the backend serving the last-good artifact, then retry the
594
- * boot build on every file change until it succeeds.
595
- */
979
+ // A boot compile error must not kill the builder, the dev server's only file watcher: report it, emit builder-ready
980
+ // so the backend keeps the last-good artifact, and retry the boot build on every change.
596
981
  static #recoverBoot(
597
982
  app: App,
598
983
  bootError: unknown,
599
984
  logger: Logger,
600
985
  runner: BuildBatchRunner,
986
+ initialGeneration: number,
601
987
  ): Promise<{ builder: IncrementalBuilder; changedFiles: string[] }> {
602
988
  const firstMessage = bootError instanceof Error ? bootError.message : String(bootError);
603
989
  logger.error(`boot build failed; entering degraded watch mode until the error is fixed: ${firstMessage}`);
604
- let generation = 0;
990
+ let generation = initialGeneration;
605
991
  const sendFailure = (files: string[], message: string) => {
606
992
  BuilderChannel.emit({
607
993
  type: "build-status",
@@ -610,6 +996,8 @@ class IncrementalBuilder {
610
996
  };
611
997
  sendFailure([], firstMessage);
612
998
  BuilderChannel.emit({ type: "builder-ready" });
999
+ // Nothing builds until a save fixes the boot, so the next app's boot need not wait for this one.
1000
+ BuilderChannel.emit({ type: "boot-armed" });
613
1001
  return new Promise((resolve, reject) => {
614
1002
  void (async () => {
615
1003
  const roots = await new WatchRootResolver(app).resolve();
@@ -648,8 +1036,7 @@ class IncrementalBuilder {
648
1036
  const app = AppExecutor.from(workspace, appName);
649
1037
  const watch = process.env.AKAN_WATCH !== "0";
650
1038
  let builder: IncrementalBuilder | null = null;
651
- // Registered before the boot build so backend requests get an error response (instead of hanging
652
- // the backend) while the builder is still booting or recovering from a failed build.
1039
+ // Registered before the boot build so a request during boot or recovery gets an error instead of hanging.
653
1040
  const bootingError = "builder is recovering from a failed boot build; retry after the build error is fixed";
654
1041
  const recyclingError = "builder is recycling to release bundler memory; retry after it restarts";
655
1042
  process.on("message", (msg: BuilderMessage) => {
@@ -662,51 +1049,52 @@ class IncrementalBuilder {
662
1049
  void builder.shutdown(msg.reason);
663
1050
  return;
664
1051
  }
665
- if (msg.type === "build-route") {
1052
+ if (msg.type !== "build-route" && msg.type !== "build-csr") return;
1053
+ if (!builder || builder.shuttingDown) {
666
1054
  const error = builder?.shuttingDown ? recyclingError : bootingError;
667
- if (!builder || builder.shuttingDown) {
668
- BuilderChannel.emit({ type: "build-route-res", id: msg.id, ok: false, error });
669
- return;
670
- }
671
- void builder.handleBuildRoute(msg);
1055
+ BuilderChannel.emit({ type: `${msg.type}-res`, id: msg.id, ok: false, error });
672
1056
  return;
673
1057
  }
674
- if (msg.type === "build-csr") {
675
- const error = builder?.shuttingDown ? recyclingError : bootingError;
676
- if (!builder || builder.shuttingDown) {
677
- BuilderChannel.emit({ type: "build-csr-res", id: msg.id, ok: false, error });
678
- return;
679
- }
680
- void builder.handleBuildCsr(msg);
681
- }
1058
+ const handled = msg.type === "build-route" ? builder.handleBuildRoute(msg) : builder.handleBuildCsr(msg);
1059
+ // Unhandled, a rejection (an ipc write that failed) would exit the builder and drop the watcher with it.
1060
+ void handled.catch((err: unknown) =>
1061
+ logger.error(`${msg.type} failed: ${err instanceof Error ? err.message : String(err)}`),
1062
+ );
682
1063
  });
683
- // The IPC channel closes when the dev host dies (including SIGKILL); exit instead of running
684
- // as an orphaned watcher that keeps rebuilding for nobody. Nothing is drained here on purpose —
685
- // there is no longer anyone on the other end to flush to.
1064
+ // Closes when the host dies (even by SIGKILL): nothing left to drain, and an orphan would rebuild for nobody.
686
1065
  process.on("disconnect", () => {
687
1066
  logger.warn("host IPC channel closed; exiting builder");
688
1067
  process.exit(0);
689
1068
  });
690
1069
  let recoveredFiles: string[] | null = null;
691
- // Owned by `main` rather than the instance: the boot build has to run before an instance exists, and
692
- // a degraded boot re-runs it once per file change until it succeeds.
693
1070
  const bootRunner = new BuildBatchRunner({ workspaceRoot, cwd: app.cwdPath });
1071
+ const initialGeneration = IncrementalBuilder.#initialGeneration();
694
1072
  try {
695
- builder = new IncrementalBuilder({ app, watch, ...(await IncrementalBuilder.#buildBootDeps(app, bootRunner)) });
1073
+ const deps = await IncrementalBuilder.#buildBootDeps(app, bootRunner);
1074
+ builder = new IncrementalBuilder({ app, watch, initialGeneration, ...deps });
696
1075
  } catch (err) {
697
1076
  if (!watch) throw err;
698
- const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner);
1077
+ const recovered = await IncrementalBuilder.#recoverBoot(app, err, logger, bootRunner, initialGeneration);
699
1078
  builder = recovered.builder;
700
1079
  recoveredFiles = recovered.changedFiles;
701
1080
  }
702
1081
  await builder.boot();
703
1082
  if (recoveredFiles) await builder.announceRecoveredState(recoveredFiles);
704
1083
  else if (process.env.AKAN_BUILDER_ANNOUNCE_BOOT === "1") await builder.announceBootState();
705
- await builder.rearmCsrFromEnv();
1084
+ await Promise.all([builder.rearmCsrFromEnv(), builder.armSsrRegistry()]);
1085
+ BuilderChannel.emit({ type: "boot-armed" });
1086
+ }
1087
+
1088
+ //? A replacement builder continues past the host's last generation: from 0, the save fixing an error read older than
1089
+ //? the failure the tabs hold, and from that generation itself its arming builds' ok did not read as the fix either.
1090
+ static #initialGeneration(): number {
1091
+ const generation = Number(process.env.AKAN_BUILDER_INITIAL_GENERATION);
1092
+ return Number.isInteger(generation) && generation > 0 ? generation + 1 : 0;
706
1093
  }
707
1094
  }
708
1095
 
709
- void IncrementalBuilder.main().catch((err) => {
1096
+ // Awaited, not voided: on Windows Bun exits during a missing file's `Bun.file` read unless an entry await is pending.
1097
+ await IncrementalBuilder.main().catch((err) => {
710
1098
  console.error(err);
711
1099
  process.exit(1);
712
1100
  });