@akanjs/devkit 3.0.0-beta.9 → 3.0.1

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 (346) hide show
  1. package/CHANGELOG.md +1819 -0
  2. package/DEV_RUNTIME_KNOBS.md +44 -26
  3. package/README.ko.md +21 -7
  4. package/README.md +17 -8
  5. package/abstractDoc.test.ts +6 -15
  6. package/abstractDoc.ts +6 -8
  7. package/agentsIndex.test.ts +4 -7
  8. package/agentsIndex.ts +6 -22
  9. package/akanApp/BackendImportGraph.test.ts +8 -22
  10. package/akanApp/BackendImportGraph.ts +1 -20
  11. package/akanApp/akanApp.host.test.ts +56 -3
  12. package/akanApp/akanApp.host.ts +218 -278
  13. package/akanApp/devBootLatch.test.ts +39 -0
  14. package/akanApp/devBootLatch.ts +39 -0
  15. package/akanApp/devHostPolicy.test.ts +81 -35
  16. package/akanApp/devHostPolicy.ts +41 -102
  17. package/akanConfig/akanBin.test.ts +62 -0
  18. package/akanConfig/akanBin.ts +78 -0
  19. package/akanConfig/akanConfig.test.ts +439 -141
  20. package/akanConfig/akanConfig.ts +359 -275
  21. package/akanConfig/index.ts +1 -0
  22. package/akanConfig/types.ts +22 -8
  23. package/akanContext.ts +110 -160
  24. package/akanMcpContract.ts +28 -73
  25. package/appSelectionMemory.ts +1 -4
  26. package/applicationBuildReporter.ts +37 -5
  27. package/applicationBuildRunner.test.ts +41 -5
  28. package/applicationBuildRunner.ts +71 -63
  29. package/applicationTestPreload.ts +3 -12
  30. package/artifact/implicitRootLayout.test.ts +34 -31
  31. package/artifact/implicitRootLayout.ts +60 -107
  32. package/artifact/routeSeedIndex.test.ts +5 -18
  33. package/artifact/routeSeedIndex.ts +11 -44
  34. package/biome.base.json +39 -2
  35. package/biomeBase.ts +2 -5
  36. package/cloud/cloudApi.test.ts +202 -0
  37. package/cloud/cloudApi.ts +77 -39
  38. package/cloud/configLock.test.ts +64 -0
  39. package/cloud/configLock.ts +96 -0
  40. package/cloud/constants.ts +26 -5
  41. package/cloud/globalConfig.ts +71 -32
  42. package/codeAgent/agent/AkanCodeServices.ts +97 -0
  43. package/codeAgent/agent/AkanContextFiles.ts +94 -0
  44. package/codeAgent/agent/CodeAgent.ts +551 -0
  45. package/codeAgent/agent/CodeAgentAsks.ts +66 -0
  46. package/codeAgent/agent/CodeAgentEventMapper.ts +225 -0
  47. package/codeAgent/agent/CodeAgentGate.ts +93 -0
  48. package/codeAgent/agent/CodeAgentProxy.ts +82 -0
  49. package/codeAgent/agent/CodeAgentRpcHost.ts +103 -0
  50. package/codeAgent/agent/CodeAgentRpcListener.ts +109 -0
  51. package/codeAgent/agent/CodeAgentStreamPrinter.ts +86 -0
  52. package/codeAgent/agent/CodeAgentSuspended.ts +85 -0
  53. package/codeAgent/agent/CodeAgentUi.ts +47 -0
  54. package/codeAgent/agent/CodeMailbox.ts +148 -0
  55. package/codeAgent/agent/CodeSessionFork.ts +56 -0
  56. package/codeAgent/agent/CodeSessionIndex.ts +78 -0
  57. package/codeAgent/agent/SubagentPool.ts +214 -0
  58. package/codeAgent/agent/akanCodeModel.ts +47 -0
  59. package/codeAgent/agent/akanCodePaths.ts +31 -0
  60. package/codeAgent/agent/akanContextFiles.test.ts +114 -0
  61. package/codeAgent/agent/akanSystemPrompt.ts +46 -0
  62. package/codeAgent/agent/codeAgent.test.ts +178 -0
  63. package/codeAgent/agent/codeAgentContract.test.ts +523 -0
  64. package/codeAgent/agent/codeAgentProxy.test.ts +74 -0
  65. package/codeAgent/agent/codeAgentRpcListener.test.ts +113 -0
  66. package/codeAgent/agent/codeAgentSuspended.test.ts +47 -0
  67. package/codeAgent/agent/codeMailbox.test.ts +76 -0
  68. package/codeAgent/agent/codeSessionFork.test.ts +58 -0
  69. package/codeAgent/agent/codeSessionIndex.test.ts +77 -0
  70. package/codeAgent/agent/consoleToStderr.ts +8 -0
  71. package/codeAgent/agent/jsonLines.ts +29 -0
  72. package/codeAgent/agent/stringArg.ts +5 -0
  73. package/codeAgent/feedback/DevLogFeedback.ts +82 -0
  74. package/codeAgent/feedback/PreviewChrome.ts +82 -0
  75. package/codeAgent/feedback/PreviewFeedback.ts +46 -0
  76. package/codeAgent/feedback/PreviewView.ts +95 -0
  77. package/codeAgent/feedback/TurnFeedback.ts +98 -0
  78. package/codeAgent/feedback/VerifyFeedback.ts +40 -0
  79. package/codeAgent/feedback/previewChrome.test.ts +73 -0
  80. package/codeAgent/index.ts +34 -0
  81. package/codeAgent/skills/akan-code/SKILL.md +130 -0
  82. package/codeAgent/skills/akan-debug/SKILL.md +42 -0
  83. package/codeAgent/skills/akan-deps/SKILL.md +50 -0
  84. package/codeAgent/skills/akan-err/SKILL.md +39 -0
  85. package/codeAgent/skills/akan-module/SKILL.md +45 -0
  86. package/codeAgent/skills/akan-scaffold/SKILL.md +76 -0
  87. package/codeAgent/skills/akan-scalar/SKILL.md +52 -0
  88. package/codeAgent/skills/akan-service/SKILL.md +34 -0
  89. package/codeAgent/skills/akan-store/SKILL.md +67 -0
  90. package/codeAgent/skills/akan-ui/SKILL.md +63 -0
  91. package/codeAgent/skills/akan-validate/SKILL.md +44 -0
  92. package/codeAgent/tools/AkanCli.ts +50 -0
  93. package/codeAgent/tools/AkanCodePlugins.ts +136 -0
  94. package/codeAgent/tools/AkanEditScope.ts +48 -0
  95. package/codeAgent/tools/AkanEnvKeys.ts +50 -0
  96. package/codeAgent/tools/AkanToolPack.ts +92 -0
  97. package/codeAgent/tools/AkanVerifier.ts +48 -0
  98. package/codeAgent/tools/AskToolPack.ts +99 -0
  99. package/codeAgent/tools/MailToolPack.ts +76 -0
  100. package/codeAgent/tools/McpClient.ts +187 -0
  101. package/codeAgent/tools/McpOAuth.ts +233 -0
  102. package/codeAgent/tools/McpOAuthLoopback.ts +64 -0
  103. package/codeAgent/tools/McpServerConfig.ts +129 -0
  104. package/codeAgent/tools/McpSignIn.ts +158 -0
  105. package/codeAgent/tools/McpTokenStore.ts +81 -0
  106. package/codeAgent/tools/McpToolPack.ts +199 -0
  107. package/codeAgent/tools/SessionToolPack.ts +91 -0
  108. package/codeAgent/tools/WebToolPack.ts +147 -0
  109. package/codeAgent/tools/akanCodePlugins.test.ts +677 -0
  110. package/codeAgent/tools/askToolPack.test.ts +116 -0
  111. package/codeAgent/tools/mcpOAuth.test.ts +612 -0
  112. package/codeAgent/tools/mcpServerConfig.test.ts +212 -0
  113. package/codegenLock.test.ts +4 -13
  114. package/codegenLock.ts +3 -17
  115. package/commandDecorators/argMeta.ts +1 -5
  116. package/commandDecorators/camelToKebabCase.ts +1 -0
  117. package/commandDecorators/command.ts +46 -102
  118. package/commandDecorators/commandBuilder.ts +30 -61
  119. package/commandDecorators/commandDecorators.test.ts +2 -10
  120. package/commandDecorators/dependencyBuilder.ts +10 -1
  121. package/commandDecorators/helpFormatter.ts +68 -145
  122. package/commandDecorators/index.ts +0 -2
  123. package/commandDecorators/targetMeta.ts +9 -7
  124. package/createTunnel.ts +7 -14
  125. package/csrE2e/csrE2eHarness.fixture.ts +506 -0
  126. package/csrE2e/csrFastRefresh.e2e.test.ts +233 -0
  127. package/csrE2e/csrPageStack.e2e.test.ts +265 -0
  128. package/csrE2e/csrPatchFirst.e2e.test.ts +87 -0
  129. package/csrE2e/csrRootLayout.e2e.test.ts +134 -0
  130. package/csrE2e/devHmrLatency.e2e.test.ts +391 -0
  131. package/csrE2e/registryVendor.e2e.test.ts +221 -0
  132. package/csrE2e/ssrRegistry.e2e.test.ts +644 -0
  133. package/dependencyScanner.test.ts +4 -16
  134. package/dependencyScanner.ts +31 -80
  135. package/devkitUtils.test.ts +48 -20
  136. package/executors.linkFallback.test.ts +103 -0
  137. package/executors.test.ts +176 -106
  138. package/executors.ts +212 -498
  139. package/fileEditor.ts +9 -37
  140. package/{getDirname.test.ts → fileSys.test.ts} +5 -3
  141. package/fileSys.ts +22 -9
  142. package/formSetterScanner.ts +3 -16
  143. package/frontendBuild/allRoutesBuilder.ts +7 -21
  144. package/frontendBuild/autoImportSync.test.ts +27 -38
  145. package/frontendBuild/autoImportSync.ts +31 -65
  146. package/frontendBuild/buildRouteClient.test.ts +123 -37
  147. package/frontendBuild/bundleDefine.test.ts +12 -0
  148. package/frontendBuild/bundleDefine.ts +16 -0
  149. package/frontendBuild/clientBuildTypes.ts +5 -36
  150. package/frontendBuild/clientEntriesBundler.ts +9 -40
  151. package/frontendBuild/clientEntryDiscovery.test.ts +112 -0
  152. package/frontendBuild/clientEntryDiscovery.ts +122 -110
  153. package/frontendBuild/csrArtifactBuilder.ts +9 -31
  154. package/frontendBuild/csrDevArtifactWriter.test.ts +151 -0
  155. package/frontendBuild/csrDevArtifactWriter.ts +307 -0
  156. package/frontendBuild/csrDevBundler.test.ts +66 -0
  157. package/frontendBuild/csrDevBundler.ts +95 -0
  158. package/frontendBuild/csrDevFactoryCheck.test.ts +33 -0
  159. package/frontendBuild/csrDevFactoryCheck.ts +39 -0
  160. package/frontendBuild/csrDevModuleCompiler.test.ts +293 -0
  161. package/frontendBuild/csrDevModuleCompiler.ts +422 -0
  162. package/frontendBuild/csrDevPatcher.test.ts +572 -0
  163. package/frontendBuild/csrDevPatcher.ts +548 -0
  164. package/frontendBuild/csrDevPaths.ts +145 -0
  165. package/frontendBuild/csrDevResolver.test.ts +239 -0
  166. package/frontendBuild/csrDevResolver.ts +363 -0
  167. package/frontendBuild/csrDevTypes.ts +61 -0
  168. package/frontendBuild/csrEntryFiles.ts +24 -0
  169. package/frontendBuild/cssCandidateCache.test.ts +0 -6
  170. package/frontendBuild/cssCandidateCache.ts +6 -41
  171. package/frontendBuild/cssCompiler.ts +31 -82
  172. package/frontendBuild/cssImportResolver.ts +6 -19
  173. package/frontendBuild/devChangePlanner.ts +7 -10
  174. package/frontendBuild/devGeneratedIndexSync.test.ts +12 -27
  175. package/frontendBuild/devGeneratedIndexSync.ts +1 -4
  176. package/frontendBuild/devRegistryBundler.ts +192 -0
  177. package/frontendBuild/fontOptimizer.test.ts +6 -14
  178. package/frontendBuild/fontOptimizer.ts +15 -72
  179. package/frontendBuild/fontPruner.test.ts +5 -10
  180. package/frontendBuild/fontPruner.ts +5 -20
  181. package/frontendBuild/frontendBuild.test.ts +57 -70
  182. package/frontendBuild/hmrChangeClassifier.ts +2 -2
  183. package/frontendBuild/hmrWatcher.test.ts +59 -32
  184. package/frontendBuild/hmrWatcher.ts +37 -74
  185. package/frontendBuild/index.ts +4 -0
  186. package/frontendBuild/pagesBundleBuilder.ts +28 -30
  187. package/frontendBuild/pagesEntrySourceGenerator.ts +56 -6
  188. package/frontendBuild/precompressArtifacts.ts +5 -18
  189. package/frontendBuild/registryMetadataFingerprint.test.ts +63 -0
  190. package/frontendBuild/registryMetadataFingerprint.ts +28 -0
  191. package/frontendBuild/routeClientBuilder.ts +100 -62
  192. package/frontendBuild/routesManifestArtifactSerializer.ts +5 -36
  193. package/frontendBuild/serverGraphFile.test.ts +77 -0
  194. package/frontendBuild/serverGraphFile.ts +93 -0
  195. package/frontendBuild/sourceMtimeIndex.test.ts +41 -97
  196. package/frontendBuild/sourceMtimeIndex.ts +19 -130
  197. package/frontendBuild/ssrBaseArtifactBuilder.ts +19 -13
  198. package/frontendBuild/ssrDevBundler.ts +85 -0
  199. package/frontendBuild/styleContract.ts +1 -8
  200. package/frontendBuild/themeValidator.ts +4 -26
  201. package/frontendBuild/watchRootResolver.test.ts +23 -42
  202. package/frontendBuild/watchRootResolver.ts +5 -27
  203. package/getRelatedCnsts.ts +12 -92
  204. package/incrementalBuilder/buildBatch.proc.ts +147 -63
  205. package/incrementalBuilder/buildBatchProtocol.ts +13 -38
  206. package/incrementalBuilder/buildBatchRunner.test.ts +65 -0
  207. package/incrementalBuilder/buildBatchRunner.ts +26 -38
  208. package/incrementalBuilder/builderChannel.test.ts +24 -49
  209. package/incrementalBuilder/builderChannel.ts +6 -37
  210. package/incrementalBuilder/builderRequestRouter.test.ts +0 -8
  211. package/incrementalBuilder/builderRequestRouter.ts +5 -24
  212. package/incrementalBuilder/builderWorkQueue.test.ts +94 -0
  213. package/incrementalBuilder/builderWorkQueue.ts +126 -0
  214. package/incrementalBuilder/devWatchBatch.test.ts +21 -24
  215. package/incrementalBuilder/incrementalBuilder.host.test.ts +53 -56
  216. package/incrementalBuilder/incrementalBuilder.host.ts +68 -81
  217. package/incrementalBuilder/incrementalBuilder.proc.test.ts +270 -0
  218. package/incrementalBuilder/incrementalBuilder.proc.ts +596 -208
  219. package/index.ts +5 -19
  220. package/integration/devResourceProbe.ts +50 -56
  221. package/integration/devStability.integration.test.ts +111 -202
  222. package/integration/devStabilityHarness.test.ts +13 -28
  223. package/integration/devStabilityHarness.ts +67 -330
  224. package/integration/ssrMemoryProbe.ts +41 -167
  225. package/libSource.test.ts +4 -15
  226. package/libSource.ts +19 -31
  227. package/lint/__fixtures__/no-document-cookie/bad.tsx +4 -0
  228. package/lint/__fixtures__/no-document-cookie/good.tsx +7 -0
  229. package/lint/__fixtures__/no-static-in-object-light-model/bad.ts +43 -0
  230. package/lint/__fixtures__/no-static-in-object-light-model/fixture.json +1 -0
  231. package/lint/__fixtures__/no-static-in-object-light-model/good.ts +42 -0
  232. package/lint/__fixtures__/no-web-only-api-outside-webkit/bad.tsx +7 -0
  233. package/lint/__fixtures__/no-web-only-api-outside-webkit/good.tsx +11 -0
  234. package/lint/__fixtures__/no-web-storage/bad.tsx +6 -0
  235. package/lint/__fixtures__/no-web-storage/good.tsx +7 -0
  236. package/lint/gritRules.test.ts +8 -24
  237. package/lint/no-deep-internal-import.grit +2 -2
  238. package/lint/no-document-cookie.grit +29 -0
  239. package/lint/no-redeclare-predefined-endpoint.grit +2 -1
  240. package/lint/no-static-in-object-light-model.grit +27 -0
  241. package/lint/no-web-only-api-outside-webkit.grit +44 -0
  242. package/lint/no-web-storage.grit +30 -0
  243. package/linter.test.ts +1 -2
  244. package/linter.ts +26 -116
  245. package/mobile/desktopBin.test.ts +182 -0
  246. package/mobile/desktopBin.ts +144 -0
  247. package/mobile/desktopServerStage.test.ts +252 -0
  248. package/mobile/desktopServerStage.ts +178 -0
  249. package/mobile/index.ts +9 -0
  250. package/mobile/mobileTarget.test.ts +57 -8
  251. package/mobile/mobileTarget.ts +45 -26
  252. package/mobile/nativeApi.test.ts +30 -0
  253. package/mobile/nativeApi.ts +40 -0
  254. package/mobile/nativeApp.test.ts +184 -0
  255. package/mobile/nativeApp.ts +454 -0
  256. package/mobile/nativeAppLine.test.ts +69 -0
  257. package/mobile/nativeAppLine.ts +71 -0
  258. package/mobile/nativeConfig.test.ts +438 -0
  259. package/mobile/nativeConfig.ts +294 -0
  260. package/mobile/nativePluginFolders.test.ts +81 -0
  261. package/mobile/nativePluginFolders.ts +60 -0
  262. package/mobile/nativeWebDir.test.ts +80 -0
  263. package/mobile/nativeWebDir.ts +66 -0
  264. package/mobile/placeholderAppId.test.ts +13 -0
  265. package/mobile/placeholderAppId.ts +16 -0
  266. package/mobile/usageDescriptions.test.ts +27 -0
  267. package/mobile/usageDescriptions.ts +24 -0
  268. package/package.json +11 -11
  269. package/packageExports.test.ts +3 -18
  270. package/packageExportsMap.ts +3 -19
  271. package/piDependencyPins.test.ts +46 -0
  272. package/platformTest/BunTestReport.test.ts +60 -0
  273. package/platformTest/BunTestReport.ts +65 -0
  274. package/platformTest/IdleWatchedProcess.test.ts +56 -0
  275. package/platformTest/IdleWatchedProcess.ts +73 -0
  276. package/platformTest/LinuxDockerTarget.ts +105 -0
  277. package/platformTest/PlatformTestRun.test.ts +64 -0
  278. package/platformTest/PlatformTestRun.ts +141 -0
  279. package/platformTest/PlatformTestTarget.ts +142 -0
  280. package/platformTest/TestSnapshot.test.ts +72 -0
  281. package/platformTest/TestSnapshot.ts +103 -0
  282. package/platformTest/WindowsSshTarget.ts +151 -0
  283. package/prompter.ts +3 -13
  284. package/qualityScanner.test.ts +9 -26
  285. package/qualityScanner.ts +200 -286
  286. package/recipeScanner.test.ts +7 -12
  287. package/recipeScanner.ts +9 -38
  288. package/repoIdentity.ts +2 -9
  289. package/routeSourceValidator.test.ts +0 -1
  290. package/routeSourceValidator.ts +7 -42
  291. package/scanInfo.ts +91 -225
  292. package/selectModel.ts +1 -2
  293. package/semver.ts +3 -5
  294. package/slicePlanner.test.ts +6 -24
  295. package/slicePlanner.ts +11 -32
  296. package/spinner.test.ts +3 -8
  297. package/spinner.ts +4 -16
  298. package/sshTunnel.ts +1 -6
  299. package/ssrScanner.test.ts +1 -6
  300. package/ssrScanner.ts +103 -113
  301. package/stripAnsi.ts +3 -0
  302. package/subspace.test.ts +19 -26
  303. package/subspace.ts +48 -115
  304. package/subspaceConfig.ts +3 -11
  305. package/testHelpers.ts +194 -0
  306. package/transforms/asyncDefaultExportDetector.ts +3 -11
  307. package/transforms/barrelAnalyzer.ts +43 -135
  308. package/transforms/barrelImportsPlugin.ts +38 -175
  309. package/transforms/externalizeFrameworkPlugin.test.ts +39 -118
  310. package/transforms/externalizeFrameworkPlugin.ts +13 -87
  311. package/transforms/moduleSyntax.ts +32 -0
  312. package/transforms/rscUseClientTransform.ts +42 -20
  313. package/transforms/serverEnvPlugin.test.ts +103 -0
  314. package/transforms/serverEnvPlugin.ts +41 -0
  315. package/transforms/transforms.test.ts +166 -44
  316. package/transforms/tsconfigPackageResolver.test.ts +6 -24
  317. package/transforms/useClientBundlePlugin.ts +6 -23
  318. package/typeChecker.ts +7 -63
  319. package/types.ts +2 -0
  320. package/uploadRelease.ts +10 -25
  321. package/workflow/artifacts.ts +18 -0
  322. package/workflow/executor.test.ts +1 -12
  323. package/workflow/executor.ts +101 -110
  324. package/workflow/index.ts +0 -1
  325. package/workflow/moduleIndex.test.ts +4 -15
  326. package/workflow/moduleIndex.ts +9 -49
  327. package/workflow/plan.ts +18 -25
  328. package/workflow/render.ts +80 -96
  329. package/workflow/rolloutGate.test.ts +1 -1
  330. package/workflow/source.ts +52 -69
  331. package/workflow/sourceAst.ts +50 -0
  332. package/workspaceLayout.test.ts +18 -3
  333. package/workspaceLayout.ts +22 -23
  334. package/abstractCompactor.ts +0 -112
  335. package/aiEditor.test.ts +0 -60
  336. package/aiEditor.ts +0 -387
  337. package/applicationReleasePackager.ts +0 -209
  338. package/capacitorApp.test.ts +0 -372
  339. package/capacitorApp.ts +0 -1648
  340. package/commandDecorators/commandMeta.ts +0 -7
  341. package/commandDecorators/types.ts +0 -10
  342. package/getDirname.ts +0 -4
  343. package/guideline.ts +0 -19
  344. package/local/optimize-resource/ipcprobe/child.ts +0 -1
  345. package/local/optimize-resource/ipcprobe/parent.ts +0 -11
  346. package/workflow/primitive.ts +0 -59
@@ -1,24 +1,17 @@
1
1
  import { afterEach, describe, expect, test } from "bun:test";
2
- import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
- import os from "node:os";
2
+ import { chmod, mkdir, writeFile } from "node:fs/promises";
4
3
  import path from "node:path";
5
4
  import type { Logger } from "akanjs/common";
5
+ import { tempDirs } from "../testHelpers";
6
6
  import { type ChangeBatch, HmrWatcher } from "./hmrWatcher";
7
7
 
8
- /**
9
- * These run against the real Bun watcher rather than a fake, because the behaviour under test only
10
- * exists there: Bun 1.3.14's recursive `fs.watch` reports about one path per ~200ms coalescing window and
11
- * discards the rest (`local/optimize-resource/06-watcher-dropped-event.md`). A fake that delivered every
12
- * event would pass no matter what the watcher did with them.
13
- */
8
+ // The real Bun watcher, not a fake: the dropped-event behaviour under test only exists there.
14
9
  const STREAM_WARMUP_MS = 600;
15
10
  const SETTLE_MS = 1_500;
16
11
  const TEST_TIMEOUT_MS = 15_000;
17
12
 
18
13
  const started: HmrWatcher[] = [];
19
- const roots: string[] = [];
20
14
  const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
21
- /** Every level `HmrWatcher` can reach, so adding a log line cannot fail a test for the wrong reason. */
22
15
  const silentLogger = {
23
16
  trace: () => undefined,
24
17
  verbose: () => undefined,
@@ -29,12 +22,6 @@ const silentLogger = {
29
22
  error: () => undefined,
30
23
  } as unknown as Logger;
31
24
 
32
- const makeRoot = async () => {
33
- const root = await mkdtemp(path.join(os.tmpdir(), "akan-hmr-watcher-"));
34
- roots.push(root);
35
- return root;
36
- };
37
-
38
25
  const seed = async (root: string, rel: string, content = "export const x = 1;\n") => {
39
26
  const abs = path.join(root, rel);
40
27
  await mkdir(path.dirname(abs), { recursive: true });
@@ -56,12 +43,63 @@ const watch = async (root: string) => {
56
43
  return { watcher, batches, seen: () => new Set(batches.flatMap((batch) => batch.files)) };
57
44
  };
58
45
 
59
- afterEach(async () => {
46
+ afterEach(() => {
60
47
  for (const watcher of started.splice(0)) watcher.stop();
61
- await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
62
48
  });
49
+ const makeRoot = tempDirs("akan-hmr-watcher-");
63
50
 
64
51
  describe("HmrWatcher", () => {
52
+ test(
53
+ "marks when a batch's window opened and when it was handed over",
54
+ async () => {
55
+ const root = await makeRoot();
56
+ const source = await seed(root, "lib/a.ts");
57
+ const { batches } = await watch(root);
58
+
59
+ const writtenAt = Date.now();
60
+ await writeFile(source, "export const x = 2;\n");
61
+ await sleep(SETTLE_MS);
62
+
63
+ const trace = batches.filter((batch) => batch.files.includes(source)).at(-1)?.trace;
64
+ expect(trace?.eventAt).toBeGreaterThanOrEqual(writtenAt);
65
+ expect(trace?.flushAt).toBeGreaterThanOrEqual(trace?.eventAt ?? Number.POSITIVE_INFINITY);
66
+ },
67
+ TEST_TIMEOUT_MS,
68
+ );
69
+
70
+ test(
71
+ "takes its debounce window from AKAN_DEV_WATCH_DEBOUNCE_MS when no option names one",
72
+ async () => {
73
+ const previous = process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
74
+ process.env.AKAN_DEV_WATCH_DEBOUNCE_MS = "400";
75
+ try {
76
+ const root = await makeRoot();
77
+ const source = await seed(root, "lib/a.ts");
78
+ const batches: ChangeBatch[] = [];
79
+ // Without the verification scan, which would otherwise hand the batch over at 250ms.
80
+ const watcher = new HmrWatcher({
81
+ roots: [root],
82
+ logger: silentLogger,
83
+ verifyDelayMs: 0,
84
+ onBatch: (batch) => void batches.push(batch),
85
+ });
86
+ started.push(watcher);
87
+ await watcher.start();
88
+ await sleep(STREAM_WARMUP_MS);
89
+
90
+ await writeFile(source, "export const x = 3;\n");
91
+ await sleep(SETTLE_MS);
92
+
93
+ const trace = batches.find((batch) => batch.files.includes(source))?.trace;
94
+ expect((trace?.flushAt ?? 0) - (trace?.eventAt ?? 0)).toBeGreaterThanOrEqual(395);
95
+ } finally {
96
+ if (previous === undefined) delete process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
97
+ else process.env.AKAN_DEV_WATCH_DEBOUNCE_MS = previous;
98
+ }
99
+ },
100
+ TEST_TIMEOUT_MS,
101
+ );
102
+
65
103
  test(
66
104
  "reports every file of a save-all",
67
105
  async () => {
@@ -69,15 +107,12 @@ describe("HmrWatcher", () => {
69
107
  const files = await Promise.all([0, 1, 2, 3, 4].map((i) => seed(root, `lib/File${i}.ts`)));
70
108
  const { batches, seen, watcher } = await watch(root);
71
109
 
72
- // No gaps between writes, so they share one coalescing window and Bun names at most one of them.
73
110
  for (const [i, abs] of files.entries()) await writeFile(abs, `export const x = ${i}00;\n`);
74
111
  await sleep(SETTLE_MS);
75
112
 
76
113
  expect(batches.length).toBeGreaterThan(0);
77
114
  for (const abs of files) expect([...seen()]).toContain(abs);
78
- // Not asserted as non-zero: the point is that the outcome above holds whether or not Bun drops
79
- // events, so this suite keeps passing if the upstream defect is ever fixed. The counter is the
80
- // signal for when the compensation can be removed.
115
+ // Not `> 0`: the suite must keep passing if Bun ever stops dropping events.
81
116
  expect(watcher.unreportedChanges).toBeGreaterThanOrEqual(0);
82
117
  },
83
118
  TEST_TIMEOUT_MS,
@@ -90,8 +125,6 @@ describe("HmrWatcher", () => {
90
125
  const source = await seed(root, "lib/a.ts");
91
126
  const { seen, batches } = await watch(root);
92
127
 
93
- // What every build ends with: a burst under `.akan/`, which the classifier ignores. Before this
94
- // was handled, the burst was the one path Bun reported and the save right after it was invisible.
95
128
  const artifactDir = path.join(root, ".akan", "artifact", "server");
96
129
  await mkdir(artifactDir, { recursive: true });
97
130
  for (let i = 0; i < 60; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(8192));
@@ -100,8 +133,6 @@ describe("HmrWatcher", () => {
100
133
  await sleep(SETTLE_MS);
101
134
 
102
135
  expect([...seen()]).toContain(source);
103
- // Exactly one batch for one save. Bun does deliver a real event for some paths the scan has already
104
- // emitted, and counting both produced a second generation and a second build for no change.
105
136
  expect(batches.filter((batch) => batch.files.includes(source))).toHaveLength(1);
106
137
  },
107
138
  TEST_TIMEOUT_MS,
@@ -119,7 +150,6 @@ describe("HmrWatcher", () => {
119
150
  for (let i = 0; i < 20; i++) await writeFile(path.join(artifactDir, `chunk-${i}.js`), "x".repeat(4096));
120
151
  await sleep(SETTLE_MS);
121
152
 
122
- // The verification scan is triggered by ignored paths, so it must not invent work from them either.
123
153
  expect(batches).toEqual([]);
124
154
  },
125
155
  TEST_TIMEOUT_MS,
@@ -161,7 +191,7 @@ describe("HmrWatcher", () => {
161
191
  TEST_TIMEOUT_MS,
162
192
  );
163
193
 
164
- test.skipIf(process.getuid?.() === 0)(
194
+ test.skipIf(process.getuid?.() === 0 || process.platform === "win32")(
165
195
  "warns at startup when part of the tree cannot be read",
166
196
  async () => {
167
197
  const root = await makeRoot();
@@ -176,12 +206,9 @@ describe("HmrWatcher", () => {
176
206
  });
177
207
  started.push(watcher);
178
208
  await watcher.start();
179
- // Restored before asserting, not after: a failed assertion would otherwise leave a directory `rm`
180
- // cannot traverse, and the leak would fail the *next* test instead of this one.
209
+ // Restored before asserting: a failed assertion would leave a directory `rm` cannot traverse.
181
210
  await chmod(path.dirname(hidden), 0o755);
182
211
 
183
- // At startup rather than at the first save, because an unreadable root means edits under it never
184
- // rebuild — waiting for a save to reveal that means waiting for the save that silently does nothing.
185
212
  expect(warnings).toHaveLength(1);
186
213
  expect(warnings[0]).toContain("will not rebuild");
187
214
  expect(warnings[0]).toContain("EACCES");
@@ -12,24 +12,12 @@ export interface WatcherOptions {
12
12
  debounceMs?: number;
13
13
  logger: Logger;
14
14
  onBatch: (batch: ChangeBatch) => void | Promise<void>;
15
- /**
16
- * Delay before re-checking mtimes for changes `fs.watch` never reported. Must clear Bun's ~200ms
17
- * coalescing window, or a write late in the same window as a delivered event stays invisible.
18
- */
15
+ /** Delay (ms, default 250) before the mtime re-check; must outlast Bun's ~200ms fs.watch coalescing window. */
19
16
  verifyDelayMs?: number;
20
17
  }
21
18
 
22
- /**
23
- * Recursive filesystem watcher with debounced batching. We deliberately keep
24
- * classification coarse (`code` / `css` / `config`) so the orchestrator can
25
- * decide whether a full rebuild-and-reload or a narrower action (e.g. CSS
26
- * hot-swap) is sufficient.
27
- *
28
- * Bun's event payloads are treated as a hint, not the answer: its recursive `fs.watch` reports about one
29
- * path per coalescing window and silently discards the rest, so what changed is resolved against a
30
- * `SourceMtimeIndex` instead (see that class for the measurements). Events still drive *when* to look,
31
- * which is what keeps this cheap — Bun does reliably deliver at least one event per window.
32
- */
19
+ // Bun delivers at least one fs.watch event per coalescing window but drops the other paths, so events decide
20
+ // when to look and the SourceMtimeIndex decides what changed.
33
21
  export class HmrWatcher {
34
22
  readonly #roots: string[];
35
23
  readonly #debounceMs: number;
@@ -38,12 +26,12 @@ export class HmrWatcher {
38
26
  readonly #logger: Logger;
39
27
  readonly #watchers: fs.FSWatcher[] = [];
40
28
  readonly #pending = new Map<string, Exclude<ChangeKind, "ignore">>();
41
- /** Paths the watcher's own events named since the last batch — diagnostics only, see `#queue`. */
42
29
  readonly #hinted = new Set<string>();
43
30
  readonly #classifier = new HmrChangeClassifier();
44
31
  readonly #index: SourceMtimeIndex;
45
32
  #timer: ReturnType<typeof setTimeout> | null = null;
46
33
  #verifyTimer: ReturnType<typeof setTimeout> | null = null;
34
+ #windowStartedAt: number | null = null;
47
35
  #stopped = false;
48
36
  #flushing = false;
49
37
  #unreportedChanges = 0;
@@ -52,26 +40,19 @@ export class HmrWatcher {
52
40
 
53
41
  constructor(opts: WatcherOptions) {
54
42
  this.#roots = [...new Set(opts.roots.map((r) => path.resolve(r)))];
55
- this.#debounceMs = opts.debounceMs ?? 80;
43
+ this.#debounceMs = opts.debounceMs ?? HmrWatcher.#envDebounceMs() ?? 30;
56
44
  this.#verifyDelayMs = opts.verifyDelayMs ?? 250;
57
45
  this.#onBatch = opts.onBatch;
58
46
  this.#logger = opts.logger;
59
47
  this.#index = new SourceMtimeIndex({ roots: this.#roots, classifier: this.#classifier });
60
48
  }
61
49
 
62
- /**
63
- * How many changes the mtime scan found that `fs.watch` never reported. Non-zero means this watcher is
64
- * compensating for the Bun defect rather than the defect being absent, which is the number to watch if
65
- * it is ever fixed upstream.
66
- */
50
+ /** Changes the mtime scan found that fs.watch never reported; non-zero means it is compensating for Bun. */
67
51
  get unreportedChanges(): number {
68
52
  return this.#unreportedChanges;
69
53
  }
70
54
 
71
- /**
72
- * Watchers are installed before the baseline is taken, so an edit made during priming is reported by
73
- * the event side even though the baseline already reflects it.
74
- */
55
+ /** Installs the watchers before priming, so an edit made while priming is still reported by the event side. */
75
56
  async start(): Promise<void> {
76
57
  for (const root of this.#roots) {
77
58
  try {
@@ -80,6 +61,12 @@ export class HmrWatcher {
80
61
  const abs = path.resolve(root, filename.toString());
81
62
  this.#queue(abs);
82
63
  });
64
+ //? Linux emits an unreadable subdirectory as an async EPERM error event, which throws when unhandled.
65
+ //? The coverage-gap scan already warns about it, so only other errors are worth a warning here.
66
+ w.on("error", (err: NodeJS.ErrnoException) => {
67
+ if (err.code === "EACCES" || err.code === "EPERM") this.#logger.verbose(`[hmr] ${err.message}`);
68
+ else this.#logger.warn(`[hmr] watcher error under ${root}: ${err.message}`);
69
+ });
83
70
  this.#watchers.push(w);
84
71
  this.#logger.verbose(`[hmr] watching ${root}`);
85
72
  } catch (err) {
@@ -88,8 +75,7 @@ export class HmrWatcher {
88
75
  }
89
76
  try {
90
77
  await this.#index.prime();
91
- // Before the first batch, because a root that is unreadable at boot blinds the whole session and
92
- // waiting for a save to surface it means waiting for a save that never rebuilds.
78
+ // At boot: edits under an unreadable root never rebuild, so no later save would surface it.
93
79
  this.#reportCoverageGaps();
94
80
  this.#logger.verbose(`[hmr] tracking ${this.#index.trackedFileCount} source files for change verification`);
95
81
  } catch (err) {
@@ -112,23 +98,14 @@ export class HmrWatcher {
112
98
  }
113
99
  }
114
100
 
115
- /**
116
- * Adopt writes the batch handler made itself (regenerated barrels, inserted imports) so the
117
- * verification scan does not spend a second generation rebuilding content this one already consumed.
118
- */
101
+ /** Baseline the batch handler's own writes so the verification scan does not rebuild them a second time. */
119
102
  async absorb(paths: string[]): Promise<void> {
120
103
  if (paths.length === 0) return;
121
104
  await this.#index.absorb(paths);
122
105
  }
123
106
 
124
- /**
125
- * An event says only *that* something happened, never reliably *what*. So with a baseline in hand it is
126
- * used purely to decide when to look, and the mtime scan names the files.
127
- *
128
- * Taking the payload as well would double-report: Bun does deliver a real event for some of the paths a
129
- * scan has already emitted, and adding it back here produced a second batch for the same save — one more
130
- * generation and one more build for no change.
131
- */
107
+ // Once primed, a payload only schedules a scan: Bun also delivers events for paths the scan already emitted,
108
+ // so adding payloads to the batch double-reports a save.
132
109
  #queue(abs: string): void {
133
110
  const kind = this.#classifier.classify(abs);
134
111
  if (!this.#index.primed) {
@@ -138,20 +115,18 @@ export class HmrWatcher {
138
115
  this.#scheduleFlush();
139
116
  return;
140
117
  }
141
- // An ignored path still means a window happened. That is the shape of the original bug — every build
142
- // ends in a burst under `.akan/`, and the burst is what Bun reports instead of the save beside it.
143
- // Rescheduling on each one folds a build's whole burst into a single scan once it goes quiet.
118
+ // An ignored path (a build's .akan/ burst) still marks a coalescing window that may hide a real save.
144
119
  if (kind === "ignore") {
145
120
  this.#scheduleVerify();
146
121
  return;
147
122
  }
148
- // Recorded only so `unreportedChanges` can tell which changes the events did name; it never decides
149
- // what is in a batch.
123
+ // Diagnostics only, for `unreportedChanges`; it never decides what is in a batch.
150
124
  this.#hinted.add(abs);
151
125
  this.#scheduleFlush();
152
126
  }
153
127
 
154
128
  #scheduleFlush(): void {
129
+ this.#windowStartedAt ??= Date.now();
155
130
  if (this.#flushing) return;
156
131
  if (this.#timer) clearTimeout(this.#timer);
157
132
  this.#timer = setTimeout(() => this.#flush(), this.#debounceMs);
@@ -168,13 +143,19 @@ export class HmrWatcher {
168
143
  try {
169
144
  while (!this.#stopped) {
170
145
  await this.#mergeDetectedChanges();
171
- if (this.#pending.size === 0) break;
146
+ if (this.#pending.size === 0) {
147
+ this.#windowStartedAt = null;
148
+ break;
149
+ }
172
150
  const files = Array.from(this.#pending.keys());
173
151
  const kinds = new Set(this.#pending.values());
152
+ const flushAt = Date.now();
153
+ const trace = { eventAt: this.#windowStartedAt ?? flushAt, flushAt };
154
+ this.#windowStartedAt = null;
174
155
  this.#pending.clear();
175
156
  this.#hinted.clear();
176
157
  try {
177
- await this.#onBatch({ files, kinds });
158
+ await this.#onBatch({ files, kinds, trace });
178
159
  } catch (e) {
179
160
  this.#logger.error(`[hmr] onBatch error: ${(e as Error).message}`);
180
161
  }
@@ -186,10 +167,6 @@ export class HmrWatcher {
186
167
  }
187
168
  }
188
169
 
189
- /**
190
- * Fold in everything the mtime index has seen change, since a delivered event names at most one of the
191
- * paths that moved in its window.
192
- */
193
170
  async #mergeDetectedChanges(): Promise<void> {
194
171
  const detected = await this.#index.collectChanges().catch((err) => {
195
172
  this.#logger.error(`[hmr] mtime scan failed: ${(err as Error).message}`);
@@ -205,8 +182,6 @@ export class HmrWatcher {
205
182
  }
206
183
  if (unreported === 0) return;
207
184
  this.#unreportedChanges += unreported;
208
- // Once at info, so it is visible that the watcher is compensating rather than the defect being absent;
209
- // per-batch detail stays at verbose because a save-all trips this on every save.
210
185
  if (!this.#reportedCompensating) {
211
186
  this.#reportedCompensating = true;
212
187
  this.#logger.verbose(
@@ -218,13 +193,6 @@ export class HmrWatcher {
218
193
  );
219
194
  }
220
195
 
221
- /**
222
- * Say so when part of the tree cannot be read, and say so again when it recovers.
223
- *
224
- * A blind spot here means edits under that path are not rebuilt at all, which is indistinguishable from
225
- * the dev server being broken. Keyed on the gap list itself so a persistent failure logs once rather than
226
- * once per save, while a *different* gap appearing still gets its own line.
227
- */
228
196
  #reportCoverageGaps(): void {
229
197
  const gaps = this.#index.coverageGaps;
230
198
  const key = gaps
@@ -247,10 +215,13 @@ export class HmrWatcher {
247
215
  );
248
216
  }
249
217
 
250
- /**
251
- * One scan after the coalescing window closes. A write that lands in the same window as an already
252
- * delivered event produces no further event of its own, so nothing else would ever look for it.
253
- */
218
+ static #envDebounceMs(): number | null {
219
+ const raw = process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
220
+ const ms = raw ? Number(raw) : Number.NaN;
221
+ return Number.isInteger(ms) && ms >= 0 ? ms : null;
222
+ }
223
+
224
+ // A write in the same window as a delivered event raises no event of its own, so one scan follows each window.
254
225
  #scheduleVerify(): void {
255
226
  if (this.#stopped || this.#verifyDelayMs <= 0) return;
256
227
  if (this.#verifyTimer) clearTimeout(this.#verifyTimer);
@@ -260,16 +231,8 @@ export class HmrWatcher {
260
231
  }, this.#verifyDelayMs);
261
232
  }
262
233
 
263
- /**
264
- * Terminates rather than looping: a scan that finds nothing schedules nothing, and the build writes its
265
- * artifacts under `.akan/` — which the classifier ignores — while codegen writes are content-guarded,
266
- * so a rebuild does not move a tracked mtime.
267
- *
268
- * The one case that does schedule another is a directory the index could not date reliably (Linux stamps
269
- * directory mtimes on a 1ms clock, see `SourceMtimeIndex`). That still terminates: the next scan is
270
- * `verifyDelayMs` later, by which point the timestamp has settled unless something is writing to that
271
- * directory right now — in which case looking again is the right answer anyway.
272
- */
234
+ // Terminates: build output lands in ignored .akan/ and codegen writes are content-guarded, so a rebuild moves no
235
+ // tracked mtime; only an unsettled directory schedules another scan.
273
236
  async #verify(): Promise<void> {
274
237
  if (this.#stopped || this.#flushing) return;
275
238
  await this.#mergeDetectedChanges();
@@ -4,10 +4,12 @@ export * from "./clientBuildTypes";
4
4
  export * from "./clientEntriesBundler";
5
5
  export * from "./clientEntryDiscovery";
6
6
  export * from "./csrArtifactBuilder";
7
+ export * from "./csrDevBundler";
7
8
  export * from "./cssCompiler";
8
9
  export * from "./cssImportResolver";
9
10
  export * from "./devChangePlanner";
10
11
  export * from "./devGeneratedIndexSync";
12
+ export * from "./devRegistryBundler";
11
13
  export * from "./fontOptimizer";
12
14
  export * from "./fontPruner";
13
15
  export * from "./hmrChangeClassifier";
@@ -17,8 +19,10 @@ export * from "./pagesEntrySourceGenerator";
17
19
  export * from "./precompressArtifacts";
18
20
  export * from "./routeClientBuilder";
19
21
  export * from "./routesManifestArtifactSerializer";
22
+ export * from "./serverGraphFile";
20
23
  export * from "./sourceMtimeIndex";
21
24
  export * from "./ssrBaseArtifactBuilder";
25
+ export * from "./ssrDevBundler";
22
26
  export * from "./styleContract";
23
27
  export * from "./themeValidator";
24
28
  export * from "./vendorSpecifiers";
@@ -1,3 +1,4 @@
1
+ import fs from "node:fs";
1
2
  import path from "node:path";
2
3
  import type { BunPlugin } from "bun";
3
4
  import type { PageEntry } from "../artifact/implicitRootLayout";
@@ -5,18 +6,17 @@ import { resolveSsrPageEntriesForApp } from "../artifact/implicitRootLayout";
5
6
  import type { App } from "../commandDecorators";
6
7
  import { createBarrelImportsPlugin } from "../transforms/barrelImportsPlugin";
7
8
  import { createExternalizeFrameworkPlugin } from "../transforms/externalizeFrameworkPlugin";
9
+ import { loaderFor } from "../transforms/moduleSyntax";
8
10
  import { transformUseClient } from "../transforms/rscUseClientTransform";
9
11
  import { createUseClientBundlePlugin } from "../transforms/useClientBundlePlugin";
12
+ import { bundleDefine } from "./bundleDefine";
13
+ import { CsrDevPaths } from "./csrDevPaths";
10
14
  import { PagesEntrySourceGenerator } from "./pagesEntrySourceGenerator";
15
+ import { ServerGraphFile } from "./serverGraphFile";
11
16
 
12
17
  export interface BuildPagesBundleResult {
13
- /** Absolute path to the emitted `pages-[hash].js`. */
14
18
  bundlePath: string;
15
- /**
16
- * Monotonic build identifier. Bun.build emits a fresh filename whenever
17
- * any input changes, but importers still benefit from a `?v=<buildId>`
18
- * query-string cache bust — `buildId` is that value.
19
- */
19
+ /** Cache-bust value for `import(bundlePath?v=<buildId>)`. */
20
20
  buildId: number;
21
21
  splitting: boolean;
22
22
  entryBytes: number;
@@ -27,10 +27,6 @@ export interface BuildPagesBundleResult {
27
27
 
28
28
  const VIRTUAL_PAGES_ENTRY = "akan-pages-entry";
29
29
 
30
- /**
31
- * Build the server-side pages bundle. The RSC worker loads the result with
32
- * `await import(bundlePath?v=buildId)`.
33
- */
34
30
  export class PagesBundleBuilder {
35
31
  #app: App;
36
32
  #command: "build" | "start";
@@ -49,6 +45,13 @@ export class PagesBundleBuilder {
49
45
  this.#pageEntries ?? (await resolveSsrPageEntriesForApp(this.#app, await this.#app.getPageKeys()));
50
46
  const entrySource = PagesEntrySourceGenerator.generate(resolvedEntries);
51
47
  const workspaceRoot = this.#app.workspace.workspaceRoot;
48
+ const dev = this.#command === "start";
49
+ const clientExports: Record<string, string[]> = {};
50
+ const onClientModule = dev
51
+ ? (file: string, exports: string[]) => {
52
+ clientExports[CsrDevPaths.realpath(file)] = exports;
53
+ }
54
+ : undefined;
52
55
  const result = await Bun.build({
53
56
  entrypoints: [VIRTUAL_PAGES_ENTRY],
54
57
  outdir: `${this.#artifactDir}/server`,
@@ -61,7 +64,8 @@ export class PagesBundleBuilder {
61
64
  chunk: "chunks/[name]-[hash].[ext]",
62
65
  asset: "assets/[name]-[hash].[ext]",
63
66
  },
64
- define: this.#define(),
67
+ define: bundleDefine(this.#app, this.#command, "ssr"),
68
+ metafile: dev,
65
69
  plugins: [
66
70
  PagesBundleBuilder.createPagesEntryPlugin(entrySource),
67
71
  PagesBundleBuilder.createCssStubPlugin(),
@@ -73,9 +77,10 @@ export class PagesBundleBuilder {
73
77
  transformUseClient(source, {
74
78
  path: args.path,
75
79
  workspaceRoot,
80
+ onClientModule,
76
81
  }),
77
82
  })
78
- : createUseClientBundlePlugin({ workspaceRoot }),
83
+ : createUseClientBundlePlugin({ workspaceRoot, onClientModule }),
79
84
  ],
80
85
  });
81
86
 
@@ -86,6 +91,8 @@ export class PagesBundleBuilder {
86
91
 
87
92
  const bundlePath = path.resolve(entryArtifact.path);
88
93
  const buildId = Date.now();
94
+ if (dev && result.metafile)
95
+ await ServerGraphFile.write(this.#artifactDir, this.#serverGraph(result.metafile, clientExports));
89
96
  const outputBytes = result.outputs.reduce((sum, output) => sum + output.size, 0);
90
97
  const chunkCount = result.outputs.filter((output) => output.kind === "chunk").length;
91
98
  this.#app.verbose(
@@ -102,6 +109,15 @@ export class PagesBundleBuilder {
102
109
  };
103
110
  }
104
111
 
112
+ #serverGraph(metafile: Bun.BuildMetafile, clientExports: Record<string, string[]>) {
113
+ const inputs = Object.keys(metafile.inputs)
114
+ .filter((input) => !input.includes("node_modules"))
115
+ .map((input) => path.resolve(input))
116
+ .filter((input) => fs.existsSync(input))
117
+ .map((input) => CsrDevPaths.realpath(input));
118
+ return { inputs, clientExports };
119
+ }
120
+
105
121
  get #artifactDir(): string {
106
122
  return `${this.#command === "build" ? this.#app.dist.cwdPath : this.#app.cwdPath}/.akan/artifact`;
107
123
  }
@@ -110,17 +126,6 @@ export class PagesBundleBuilder {
110
126
  return process.env.AKAN_SERVER_PAGES_SPLITTING === "1";
111
127
  }
112
128
 
113
- #define(): Record<string, string> {
114
- const nodeEnv = this.#command === "build" ? "production" : (process.env.NODE_ENV ?? "development");
115
- return {
116
- "process.env.NODE_ENV": JSON.stringify(nodeEnv),
117
- "process.env.AKAN_PUBLIC_RENDER_ENV": JSON.stringify("ssr"),
118
- ...Object.fromEntries(
119
- Object.entries(this.#app.getPublicEnv()).map(([key, value]) => [`process.env.${key}`, JSON.stringify(value)]),
120
- ),
121
- };
122
- }
123
-
124
129
  static createPagesEntryPlugin(source: string): BunPlugin {
125
130
  return {
126
131
  name: "akan-pages-entry",
@@ -176,10 +181,3 @@ export class PagesBundleBuilder {
176
181
  );
177
182
  }
178
183
  }
179
-
180
- function loaderFor(absPath: string): "ts" | "tsx" | "js" | "jsx" {
181
- if (absPath.endsWith(".tsx")) return "tsx";
182
- if (absPath.endsWith(".jsx")) return "jsx";
183
- if (absPath.endsWith(".ts")) return "ts";
184
- return "js";
185
- }
@@ -2,11 +2,19 @@ import path from "node:path";
2
2
  import type { PageEntry } from "../artifact/implicitRootLayout";
3
3
  import { AsyncDefaultExportDetector } from "../transforms/asyncDefaultExportDetector";
4
4
 
5
+ export interface PagesEntrySourceOptions {
6
+ /** Detects an async default export; a caller that outlives one generation keeps its own cache across saves. */
7
+ isAsyncDefault?: (moduleAbsPath: string) => Promise<boolean>;
8
+ }
9
+
5
10
  export class PagesEntrySourceGenerator {
6
11
  #pageEntries: PageEntry[];
12
+ readonly #asyncDefaults = new Map<string, Promise<boolean>>();
13
+ readonly #detect: (moduleAbsPath: string) => Promise<boolean>;
7
14
 
8
- constructor(pageEntries: PageEntry[]) {
15
+ constructor(pageEntries: PageEntry[], { isAsyncDefault }: PagesEntrySourceOptions = {}) {
9
16
  this.#pageEntries = pageEntries;
17
+ this.#detect = isAsyncDefault ?? ((moduleAbsPath) => AsyncDefaultExportDetector.detect(moduleAbsPath));
10
18
  }
11
19
 
12
20
  static generate(pageEntries: PageEntry[]): string {
@@ -21,25 +29,67 @@ export class PagesEntrySourceGenerator {
21
29
  return `export const pages = {\n${lines.join("\n")}\n};\n`;
22
30
  }
23
31
 
24
- static async generateStatic(pageEntries: PageEntry[]): Promise<string> {
25
- return await new PagesEntrySourceGenerator(pageEntries).generateStatic();
32
+ static async generateStatic(pageEntries: PageEntry[], { fromDir }: { fromDir?: string } = {}): Promise<string> {
33
+ return await new PagesEntrySourceGenerator(pageEntries).generateStatic({ fromDir });
26
34
  }
27
35
 
28
- async generateStatic(): Promise<string> {
36
+ // `fromDir` makes the specifiers relative: Bun inlines an absolute import of a file that is also an entrypoint.
37
+ async generateStatic({ fromDir }: { fromDir?: string } = {}): Promise<string> {
29
38
  const imports = this.#pageEntries.map(({ moduleAbsPath }, index) => {
30
- const specifier = PagesEntrySourceGenerator.#toImportSpecifier(moduleAbsPath);
39
+ const specifier = fromDir
40
+ ? PagesEntrySourceGenerator.#toRelativeSpecifier(fromDir, moduleAbsPath)
41
+ : PagesEntrySourceGenerator.#toImportSpecifier(moduleAbsPath);
31
42
  return `import * as page${index} from ${JSON.stringify(specifier)};`;
32
43
  });
33
44
  const entries = await Promise.all(
34
45
  this.#pageEntries.map(async ({ key, moduleAbsPath }, index) => {
35
- const isAsyncDefault = await AsyncDefaultExportDetector.detect(moduleAbsPath);
46
+ const isAsyncDefault = await this.#isAsyncDefault(moduleAbsPath);
36
47
  return ` ${JSON.stringify(key)}: { loader: async () => page${index}, isAsyncDefault: ${isAsyncDefault} },`;
37
48
  }),
38
49
  );
39
50
  return `${imports.join("\n")}\nexport const pages = {\n${entries.join("\n")}\n};\n`;
40
51
  }
41
52
 
53
+ // Route HMR in the dev registry bundle: `akanWebkit` is the entry's namespace import of `akanjs/webkit`.
54
+ async generateHotReplace({
55
+ fromDir,
56
+ ownerId,
57
+ moduleIds,
58
+ }: {
59
+ fromDir: string;
60
+ ownerId: string;
61
+ moduleIds: string[];
62
+ }): Promise<string> {
63
+ const entries = await Promise.all(
64
+ this.#pageEntries.map(async ({ key, moduleAbsPath }) => {
65
+ const specifier = PagesEntrySourceGenerator.#toRelativeSpecifier(fromDir, moduleAbsPath);
66
+ const isAsyncDefault = await this.#isAsyncDefault(moduleAbsPath);
67
+ return ` ${JSON.stringify(key)}: { loader: async () => require(${JSON.stringify(specifier)}), isAsyncDefault: ${isAsyncDefault} },`;
68
+ }),
69
+ );
70
+ return `__akan.accept(${JSON.stringify(ownerId)}, ${JSON.stringify(moduleIds)}, async () => {
71
+ const replaced = await akanWebkit.replacePages({
72
+ ${entries.join("\n")}
73
+ });
74
+ if (!replaced) throw new Error("the route table no longer matches the pages it was built from");
75
+ });
76
+ `;
77
+ }
78
+
79
+ #isAsyncDefault(moduleAbsPath: string): Promise<boolean> {
80
+ const known = this.#asyncDefaults.get(moduleAbsPath);
81
+ if (known) return known;
82
+ const detected = this.#detect(moduleAbsPath);
83
+ this.#asyncDefaults.set(moduleAbsPath, detected);
84
+ return detected;
85
+ }
86
+
42
87
  static #toImportSpecifier(moduleAbsPath: string): string {
43
88
  return path.resolve(moduleAbsPath).split(path.sep).join("/");
44
89
  }
90
+
91
+ static #toRelativeSpecifier(fromDir: string, moduleAbsPath: string): string {
92
+ const relative = path.relative(fromDir, path.resolve(moduleAbsPath)).split(path.sep).join("/");
93
+ return relative.startsWith(".") ? relative : `./${relative}`;
94
+ }
45
95
  }