@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,33 +1,26 @@
1
1
  import path from "node:path";
2
+ import { ApplicationBuildReporter } from "@akanjs/devkit/applicationBuildReporter";
3
+ import { resolveSsrPageEntriesForApp } from "@akanjs/devkit/artifact/implicitRootLayout";
4
+ import { computeRouteSeedIndex, saveRouteSeedIndex } from "@akanjs/devkit/artifact/routeSeedIndex";
2
5
  import type { App } from "@akanjs/devkit/commandDecorators";
3
- // Subpath imports only, and as few as possible: this process is spawned once per generation, so every
4
- // eager import is paid on every save. Measured on a 177-route app: `executors` 24ms, `frontendBuild`
5
- // ~110ms, app config 5ms.
6
+ // Subpath imports only, as few as possible: spawned per generation, this process pays every import on every save.
6
7
  import { AppExecutor, WorkspaceExecutor } from "@akanjs/devkit/executors";
7
8
  import {
8
9
  CsrArtifactBuilder,
10
+ CsrDevBundler,
9
11
  CssCompiler,
10
12
  FontOptimizer,
11
13
  PagesBundleBuilder,
14
+ ServerGraphFile,
12
15
  SsrBaseArtifactBuilder,
16
+ SsrDevBundler,
13
17
  } from "@akanjs/devkit/frontendBuild";
14
18
  import { Logger } from "akanjs/common";
15
- import type { BuilderMessage, BuildPhase } from "akanjs/server";
19
+ import type { BuilderMessage, BuildPhase, HmrTrace } from "akanjs/server";
20
+ import { resolveDevCsrMode } from "akanjs/server/hmr/csrDevManifest";
16
21
  import type { BuildBatchRequest, BuildBatchResult, OptimizedFonts, PagesBatchCssAssets } from "./buildBatchProtocol";
17
22
 
18
- /**
19
- * One generation of frontend build work, in a process that exits when it is done.
20
- *
21
- * This exists for one reason: `Bun.build` retains native bundler arenas that the process never returns
22
- * to the OS — `Bun.gc(true)` reclaims nothing and the JS heap stays flat while RSS climbs ~250MB per
23
- * save. Exit is the only mechanism that gives that memory back, so the work that scales per save lives
24
- * here rather than in the long-lived watcher.
25
- *
26
- * Nothing is cached here, by design. That costs less than it appears to: `CssCompiler` rebuilds its
27
- * tailwind compilers on every `compileCss` call, and the watcher always asked for `refresh: true`, so
28
- * there was no warm state to lose. What genuinely had to be preserved travels in the request — the
29
- * validated page keys and the previous font optimization.
30
- */
23
+ // `Bun.build` keeps native bundler arenas that `Bun.gc(true)` cannot reclaim; only exiting returns them.
31
24
  class BuildBatch {
32
25
  #logger = new Logger("BuildBatch");
33
26
  #request: BuildBatchRequest;
@@ -40,33 +33,27 @@ class BuildBatch {
40
33
  }
41
34
 
42
35
  async run(): Promise<BuildBatchResult> {
43
- // `base` arrives alone, from a builder that cannot serve anything until it finishes.
44
36
  if (this.#request.needs.includes("base")) await this.#buildBase();
45
- // Ordered the way the watcher used to run them: csr before pages so a csr failure cannot delay the
46
- // pages bundle the browser is waiting on, and css last because it depends on the rebuilt client.
47
37
  if (this.#request.needs.includes("csr")) await this.#buildCsr();
48
- if (this.#request.needs.includes("pages")) await this.#buildPages();
38
+ if (this.#request.needs.includes("ssr")) await this.#buildSsrRegistry();
39
+ // Css before pages: it scans the sources and reads nothing pages produces, so a class edit need not wait for pages.
49
40
  if (this.#request.needs.includes("css")) await this.#buildCss();
41
+ if (this.#request.needs.includes("pages")) await this.#buildPages();
50
42
  return this.#result;
51
43
  }
52
44
 
53
- /**
54
- * Broadcast as soon as a need finishes rather than when the batch does. The browser is waiting on the
55
- * pages bundle; making it wait for the css compile behind it would add latency the in-process version
56
- * never had, and it would move every artifact write into the window right before the watcher reports
57
- * the generation complete — which is where a save issued immediately afterwards gets dropped by Bun's
58
- * recursive `fs.watch` (`local/optimize-resource/06-watcher-dropped-event.md`).
59
- *
60
- * A bare `process.send` is safe here, unlike in the watcher, for one reason: this process ends by
61
- * returning from `main`, and a natural exit flushes a pending ipc write (measured: 1MB delivered
62
- * 20/20). It is `process.exit` that discards one — so adding an explicit exit to this file, at the end
63
- * of `main` or anywhere after an emit, would silently start dropping `css-updated` payloads. Route
64
- * sends through `BuilderChannel` if that ever becomes necessary.
65
- */
45
+ // A bare `process.send` is safe only because this process exits by returning from `main`, which flushes ipc;
46
+ // an explicit `process.exit` after an emit would silently drop payloads (route through BuilderChannel then).
66
47
  #emit(message: BuilderMessage): void {
67
48
  process.send?.(message);
68
49
  }
69
50
 
51
+ #sentTrace(): HmrTrace | undefined {
52
+ if (!this.#request.trace) return undefined;
53
+ const now = Date.now();
54
+ return { ...this.#request.trace, patchAt: now, sentAt: now };
55
+ }
56
+
70
57
  #emitStatus(phase: BuildPhase, message?: string): void {
71
58
  this.#emit({
72
59
  type: "build-status",
@@ -80,11 +67,15 @@ class BuildBatch {
80
67
  });
81
68
  }
82
69
 
83
- /**
84
- * The boot build. Streams nothing: a builder is not serving yet, so there is no phase board to update
85
- * and no browser to reload — the watcher learns the outcome from the batch result, and a failure there
86
- * is what puts it into degraded watch mode.
87
- */
70
+ // `Bun.build` rejects with an AggregateError whose own message is only "Bundle failed"; the reasons are nested.
71
+ #fail(need: keyof BuildBatchResult["errors"], label: string, err: unknown): void {
72
+ const message = ApplicationBuildReporter.formatError(err, this.#request.workspaceRoot);
73
+ this.#logger.error(`${label} failed: ${message}`);
74
+ this.#result.errors[need] = message;
75
+ if (need !== "base") this.#emitStatus(need, message);
76
+ }
77
+
78
+ // Streams nothing: the builder is not serving yet, so the watcher reads the outcome from the batch result.
88
79
  async #buildBase(): Promise<void> {
89
80
  const started = Date.now();
90
81
  try {
@@ -93,30 +84,115 @@ class BuildBatch {
93
84
  this.#result.optimizedFonts = optimizedFonts;
94
85
  this.#logger.verbose(`base-artifact ok buildId=${artifact.pagesBundleBuildId} (${Date.now() - started}ms)`);
95
86
  } catch (err) {
96
- const message = err instanceof Error ? err.message : String(err);
97
- this.#logger.error(`base-artifact failed: ${message}`);
98
- this.#result.errors.base = message;
87
+ this.#fail("base", "base-artifact", err);
99
88
  }
100
89
  }
101
90
 
102
91
  async #buildCsr(): Promise<void> {
103
92
  const started = Date.now();
104
93
  try {
105
- await new CsrArtifactBuilder(this.#app).build();
106
- this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
94
+ if (resolveDevCsrMode() === "registry") await this.#updateCsrRegistry(started);
95
+ else await this.#rebuildCsrArtifact(started);
107
96
  this.#emitStatus("csr");
108
97
  } catch (err) {
109
- const message = err instanceof Error ? err.message : String(err);
110
- this.#logger.error(`csr-rebundle failed: ${message}`);
111
- this.#result.errors.csr = message;
112
- this.#emitStatus("csr", message);
98
+ this.#fail("csr", "csr-rebundle", err);
99
+ }
100
+ }
101
+
102
+ async #rebuildCsrArtifact(started: number): Promise<void> {
103
+ await new CsrArtifactBuilder(this.#app).build();
104
+ this.#logger.verbose(`csr-rebundle ok (${Date.now() - started}ms)`);
105
+ // A CSR tab takes none of the SSR refresh messages, so a rebuilt artifact reaches it only as this reload.
106
+ if (this.#request.changedFiles.length === 0) return;
107
+ this.#emit({
108
+ type: "csr-updated",
109
+ data: { generation: Date.now(), mode: "artifact", reload: true, reason: "the CSR artifact was rebuilt" },
110
+ });
111
+ }
112
+
113
+ async #updateCsrRegistry(started: number): Promise<void> {
114
+ const update = await new CsrDevBundler(this.#app).update(this.#request.changedFiles, {
115
+ announce: (announced) =>
116
+ this.#emit({
117
+ type: "csr-updated",
118
+ data: {
119
+ generation: announced.generation,
120
+ mode: "registry",
121
+ reload: announced.reload,
122
+ reason: announced.reason,
123
+ patchUrl: announced.patchUrl,
124
+ changedIds: announced.changedIds,
125
+ trace: this.#sentTrace(),
126
+ },
127
+ }),
128
+ });
129
+ if (!update) {
130
+ this.#logger.verbose(`csr-dev unchanged (${Date.now() - started}ms)`);
131
+ return;
132
+ }
133
+ this.#logger.verbose(
134
+ `csr-dev generation=${update.generation} ${update.reload ? `reload (${update.reason})` : `patch modules=${update.changedIds.length}`} graph=${update.moduleCount} (${Date.now() - started}ms)`,
135
+ );
136
+ }
137
+
138
+ // Takes every entry the routes reach as a root, so a route build naming any of them finds it in the registry.
139
+ async #buildSsrRegistry(): Promise<void> {
140
+ const started = Date.now();
141
+ try {
142
+ const bundler = new SsrDevBundler(this.#app);
143
+ //? Runs before pages, so the graph is the last build's, as in the resident builder: a save it holds still waits.
144
+ const hold = await ServerGraphFile.touches(
145
+ await ServerGraphFile.read(this.#request.artifactDir),
146
+ this.#request.changedFiles,
147
+ (file) => ServerGraphFile.clientExportsOf(file),
148
+ );
149
+ const update = await bundler.update(this.#request.changedFiles, {
150
+ roots: await bundler.clientEntries(),
151
+ announce: (announced) =>
152
+ this.#emit({
153
+ type: "ssr-updated",
154
+ data: {
155
+ generation: announced.generation,
156
+ reload: announced.reload,
157
+ reason: announced.reason,
158
+ patchUrl: announced.patchUrl,
159
+ changedIds: announced.changedIds,
160
+ trace: this.#sentTrace(),
161
+ ...(announced.epoch !== undefined ? { epoch: announced.epoch } : {}),
162
+ ...(announced.first ? { first: true } : {}),
163
+ ...(hold && this.#request.changedFiles.length > 0
164
+ ? { hold, batchGeneration: this.#request.generation }
165
+ : {}),
166
+ },
167
+ }),
168
+ });
169
+ this.#emitStatus("ssr");
170
+ this.#logger.verbose(
171
+ update
172
+ ? `ssr-dev generation=${update.generation} ${update.reload ? `reload (${update.reason})` : `patch modules=${update.changedIds.length}`} graph=${update.moduleCount} (${Date.now() - started}ms)`
173
+ : `ssr-dev unchanged (${Date.now() - started}ms)`,
174
+ );
175
+ } catch (err) {
176
+ this.#fail("ssr", "ssr-dev", err);
113
177
  }
114
178
  }
115
179
 
180
+ // Rewritten with the bundle: the backend rereads it on `pages-updated` to pick up added, moved or deleted routes.
116
181
  async #buildPages(): Promise<void> {
117
182
  const started = Date.now();
118
183
  try {
119
- const next = await new PagesBundleBuilder(this.#app).build();
184
+ const pageEntries = await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys());
185
+ const seedIndex = computeRouteSeedIndex(pageEntries);
186
+ const previousGraph = await ServerGraphFile.read(this.#request.artifactDir);
187
+ const next = await new PagesBundleBuilder(this.#app, "start", pageEntries).build();
188
+ await saveRouteSeedIndex(this.#request.artifactDir, seedIndex);
189
+ const nextGraph = await ServerGraphFile.read(this.#request.artifactDir);
190
+ const serverTouched = await ServerGraphFile.touches(
191
+ previousGraph,
192
+ [...this.#request.changedFiles, ...(previousGraph?.carried ?? [])],
193
+ (file) => nextGraph?.clientExports[file] ?? null,
194
+ nextGraph,
195
+ );
120
196
  this.#emit({
121
197
  type: "pages-updated",
122
198
  data: {
@@ -124,15 +200,17 @@ class BuildBatch {
124
200
  buildId: next.buildId,
125
201
  generation: this.#request.generation,
126
202
  changedFiles: this.#request.changedFiles,
203
+ trace: this.#sentTrace(),
204
+ serverTouched,
127
205
  },
128
206
  });
129
207
  this.#emitStatus("pages");
130
208
  this.#logger.verbose(`pages-rebundle ok buildId=${next.buildId} (${Date.now() - started}ms)`);
131
209
  } catch (err) {
132
- const message = err instanceof Error ? err.message : String(err);
133
- this.#logger.error(`pages-rebundle failed: ${message}`);
134
- this.#result.errors.pages = message;
135
- this.#emitStatus("pages", message);
210
+ this.#fail("pages", "pages-rebundle", err);
211
+ await ServerGraphFile.carry(this.#request.artifactDir, this.#request.changedFiles).catch((carryError: unknown) =>
212
+ this.#logger.warn(`server graph carry failed; the next save may skip its RSC refresh: ${String(carryError)}`),
213
+ );
136
214
  }
137
215
  }
138
216
 
@@ -174,14 +252,10 @@ class BuildBatch {
174
252
  });
175
253
  this.#logger.verbose(`css-compile ok assets=${Object.keys(cssAssets).length} (${Date.now() - started}ms)`);
176
254
  } catch (err) {
177
- const message = err instanceof Error ? err.message : String(err);
178
- this.#logger.error(`css-rebuild failed: ${message}`);
179
- this.#result.errors.css = message;
180
- this.#emitStatus("css", message);
255
+ this.#fail("css", "css-rebuild", err);
181
256
  }
182
257
  }
183
258
 
184
- /** Fonts are expensive and rarely change, so the previous result is reused unless this batch touched it. */
185
259
  async #optimizeFonts(): Promise<OptimizedFonts> {
186
260
  const previous = this.#request.optimizedFonts;
187
261
  if (previous && !BuildBatch.#shouldReoptimizeFonts(previous, this.#request.changedFiles)) {
@@ -196,7 +270,6 @@ class BuildBatch {
196
270
  }
197
271
 
198
272
  static #shouldReoptimizeFonts(previous: OptimizedFonts, changedFiles: string[]): boolean {
199
- if (changedFiles.length === 0) return false;
200
273
  return changedFiles.some((file) => {
201
274
  const normalized = path.resolve(file);
202
275
  if (/\.(woff2?|ttf|otf)$/i.test(normalized)) return true;
@@ -207,21 +280,32 @@ class BuildBatch {
207
280
  static async main(): Promise<void> {
208
281
  const raw = process.argv[2];
209
282
  if (!raw) throw new Error("[build-batch] missing request argument");
210
- const request = JSON.parse(raw) as BuildBatchRequest;
283
+ const parsed = JSON.parse(raw) as BuildBatchRequest;
284
+ const request = parsed.trace
285
+ ? {
286
+ ...parsed,
287
+ trace: { ...parsed.trace, workerStartAt: Math.round(performance.timeOrigin), workerAt: Date.now() },
288
+ }
289
+ : parsed;
211
290
  const workspace = WorkspaceExecutor.fromRoot({
212
291
  workspaceRoot: request.workspaceRoot,
213
292
  repoName: request.repoName,
214
293
  });
215
294
  const app = AppExecutor.from(workspace, request.appName);
216
- // Seeded rather than rediscovered: the watcher already globbed and validated every route source,
217
- // and repeating that here would be the single largest cost of spawning this process.
295
+ // Seeded, not rediscovered: route discovery would be the largest cost of spawning this process.
218
296
  if (request.pageKeys) app.setPageKeys(request.pageKeys);
297
+ //? Exits with the builder that spawned it (a kill, a crash, a restart for a metadata save): left running, it would
298
+ //? write a registry its replacement is rebuilding. Removed before returning, since the listener keeps Bun's IPC open.
299
+ const orphaned = () => process.exit(1);
300
+ process.on("disconnect", orphaned);
219
301
  const result = await new BuildBatch(request, app).run();
220
302
  process.send?.({ type: "build-batch-result", data: result });
303
+ process.off("disconnect", orphaned);
221
304
  }
222
305
  }
223
306
 
224
- void BuildBatch.main().catch((err) => {
307
+ // Awaited, not voided: on Windows Bun exits during a missing file's `Bun.file` read unless an entry await is pending.
308
+ await BuildBatch.main().catch((err) => {
225
309
  console.error(err);
226
310
  process.exit(1);
227
311
  });
@@ -1,25 +1,12 @@
1
1
  import type { FontOptimizer } from "@akanjs/devkit/frontendBuild";
2
- import type { BaseBuildArtifact, CssPayload, PagesBundlePayload } from "akanjs/server";
2
+ import type { BaseBuildArtifact, CssPayload, HmrTrace, PagesBundlePayload } from "akanjs/server";
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
- */
12
- export type BuildBatchNeed = "base" | "pages" | "css" | "csr";
6
+ // `base` is the boot build: it always runs alone, before the builder can serve anything.
7
+ export type BuildBatchNeed = "base" | "pages" | "css" | "csr" | "ssr";
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,25 @@ 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;
21
+ trace?: HmrTrace;
38
22
  }
39
23
 
40
24
  export type PagesBatchCssAssets = CssPayload["cssAssets"];
41
25
 
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
- */
26
+ // Browser payloads stream as each need finishes; only state the next batch needs travels back here.
49
27
  export interface BuildBatchResult {
50
28
  generation: number;
51
29
  cssAssets?: PagesBatchCssAssets;
52
30
  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;
31
+ artifact?: BaseBuildArtifact; // `base` batches only
58
32
  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;
33
+ crashed?: boolean; // died before reporting its result
34
+ /** Of a crashed batch, the needs it died before reporting: the others streamed their build-status first. */
35
+ crashedNeeds?: BuildBatchNeed[];
61
36
  }
62
37
 
63
38
  export type BuildBatchMessage = { type: "build-batch-result"; data: BuildBatchResult };
@@ -0,0 +1,65 @@
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
+
35
+ test("a worker that dies mid-batch keeps what each need reported, and fails only the needs it never reached", async () => {
36
+ const { root } = track(await createTempApp("demo"));
37
+ const appDir = path.join(root, "apps/demo");
38
+ await writeText(
39
+ path.join(root, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
40
+ `const { generation } = JSON.parse(process.argv[2]);
41
+ const status = (phase, message) =>
42
+ process.send({ type: "build-status", data: { generation, phase, ok: !message, files: [], message } });
43
+ status("ssr");
44
+ status("css", "css broke");
45
+ setTimeout(() => process.exit(1), 50);
46
+ `,
47
+ );
48
+ const result = await new BuildBatchRunner({ workspaceRoot: root, cwd: appDir }).run({
49
+ appName: "demo",
50
+ workspaceRoot: root,
51
+ repoName: "repo",
52
+ generation: 7,
53
+ needs: ["ssr", "css", "pages"],
54
+ changedFiles: [],
55
+ pageKeys: null,
56
+ optimizedFonts: null,
57
+ cssAssets: null,
58
+ artifactDir: path.join(appDir, ".akan/artifact"),
59
+ });
60
+ expect(result).toMatchObject({ crashed: true, crashedNeeds: ["pages"] });
61
+ expect(result.errors.ssr).toBeUndefined();
62
+ expect(result.errors.css).toBe("css broke");
63
+ expect(result.errors.pages).toContain("exited with code 1");
64
+ }, 30_000);
65
+ });
@@ -3,25 +3,10 @@ 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: a worker exiting after its batch returns the arenas `Bun.build` never frees. The callers order
7
+ // their own work (the slow lane, the css queue, the SSR registry's boot build beside them), so a few can overlap.
8
+ // A worker that dies without reporting must not take the watcher down; its needs come back as errors.
18
9
  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
10
  #logger = new Logger("BuildBatchRunner");
26
11
  #entry: string | null = null;
27
12
  #workspaceRoot: string;
@@ -33,7 +18,12 @@ export class BuildBatchRunner {
33
18
 
34
19
  async #resolveEntry(): Promise<string> {
35
20
  if (this.#entry) return this.#entry;
36
- const candidates = BuildBatchRunner.#entryCandidates(this.#workspaceRoot);
21
+ const candidates = [
22
+ path.join(this.#workspaceRoot, "pkgs/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
23
+ path.join(this.#workspaceRoot, "node_modules/@akanjs/devkit/incrementalBuilder/buildBatch.proc.ts"),
24
+ path.join(import.meta.dir, "buildBatch.proc.js"),
25
+ path.join(import.meta.dir, "buildBatch.proc.ts"),
26
+ ];
37
27
  for (const candidate of candidates) {
38
28
  if (!(await Bun.file(candidate).exists())) continue;
39
29
  this.#entry = candidate;
@@ -42,11 +32,6 @@ export class BuildBatchRunner {
42
32
  throw new Error(`[build-batch] worker entry not found; looked in: ${candidates.join(", ")}`);
43
33
  }
44
34
 
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
35
  async run(
51
36
  request: BuildBatchRequest,
52
37
  onMessage: (message: BuilderMessage) => void = () => undefined,
@@ -54,9 +39,10 @@ export class BuildBatchRunner {
54
39
  const started = Date.now();
55
40
  const entry = await this.#resolveEntry();
56
41
  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.
59
- const proc = Bun.spawn(["bun", entry, JSON.stringify(request)], {
42
+ const reported = new Map<string, string | undefined>();
43
+ const payload = request.trace ? { ...request, trace: { ...request.trace, spawnAt: Date.now() } } : request;
44
+ // argv rather than IPC, so the worker starts on its first tick instead of waiting for a handshake.
45
+ const proc = Bun.spawn(["bun", entry, JSON.stringify(payload)], {
60
46
  cwd: this.#cwd,
61
47
  env: process.env,
62
48
  stdio: ["ignore", "inherit", "inherit"],
@@ -64,7 +50,10 @@ export class BuildBatchRunner {
64
50
  ipc: (message: BuildBatchMessage | BuilderMessage) => {
65
51
  if (!message || typeof message !== "object") return;
66
52
  if (message.type === "build-batch-result") result = message.data;
67
- else onMessage(message);
53
+ else {
54
+ if (message.type === "build-status") reported.set(message.data.phase, message.data.message);
55
+ onMessage(message);
56
+ }
68
57
  },
69
58
  });
70
59
  const exitCode = await proc.exited;
@@ -74,21 +63,20 @@ export class BuildBatchRunner {
74
63
  );
75
64
  return result;
76
65
  }
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.
66
+ // An OOM kill (code null, SIGKILL) reads like a crash without the signal, and needs the opposite fix.
82
67
  const message = proc.signalCode
83
68
  ? `build worker was killed by ${proc.signalCode} before reporting a result${
84
69
  proc.signalCode === "SIGKILL" ? " — most often the kernel OOM killer; check the sandbox's memory limit" : ""
85
70
  }`
86
71
  : `build worker exited with code ${exitCode} before reporting a result`;
87
72
  this.#logger.error(`[build-batch] generation=${request.generation} ${message}`);
88
- return {
89
- generation: request.generation,
90
- errors: Object.fromEntries(request.needs.map((need) => [need, message])),
91
- crashed: true,
92
- };
73
+ //? A need that reported before the crash keeps what it reported: a worker killed during pages built ssr and css.
74
+ const crashedNeeds = request.needs.filter((need) => !reported.has(need));
75
+ const failed = request.needs.flatMap((need) => {
76
+ if (!reported.has(need)) return [[need, message] as const];
77
+ const error = reported.get(need);
78
+ return error ? [[need, error] as const] : [];
79
+ });
80
+ return { generation: request.generation, errors: Object.fromEntries(failed), crashed: true, crashedNeeds };
93
81
  }
94
82
  }