@akanjs/devkit 3.0.0-beta.3 → 3.0.0-beta.30

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 +2 -2
  3. package/README.md +3 -2
  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 +404 -141
  19. package/akanConfig/akanConfig.ts +317 -274
  20. package/akanConfig/index.ts +1 -0
  21. package/akanConfig/types.ts +21 -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 +269 -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 +177 -0
  241. package/mobile/desktopBin.ts +143 -0
  242. package/mobile/desktopServerStage.test.ts +204 -0
  243. package/mobile/desktopServerStage.ts +138 -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 +151 -0
  250. package/mobile/nativeApp.ts +405 -0
  251. package/mobile/nativeAppLine.test.ts +69 -0
  252. package/mobile/nativeAppLine.ts +71 -0
  253. package/mobile/nativeConfig.test.ts +414 -0
  254. package/mobile/nativeConfig.ts +296 -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 +30 -25
  298. package/subspace.ts +81 -115
  299. package/subspaceConfig.ts +11 -9
  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,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 {
@@ -88,8 +69,7 @@ export class HmrWatcher {
88
69
  }
89
70
  try {
90
71
  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.
72
+ // At boot: edits under an unreadable root never rebuild, so no later save would surface it.
93
73
  this.#reportCoverageGaps();
94
74
  this.#logger.verbose(`[hmr] tracking ${this.#index.trackedFileCount} source files for change verification`);
95
75
  } catch (err) {
@@ -112,23 +92,14 @@ export class HmrWatcher {
112
92
  }
113
93
  }
114
94
 
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
- */
95
+ /** Baseline the batch handler's own writes so the verification scan does not rebuild them a second time. */
119
96
  async absorb(paths: string[]): Promise<void> {
120
97
  if (paths.length === 0) return;
121
98
  await this.#index.absorb(paths);
122
99
  }
123
100
 
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
- */
101
+ // Once primed, a payload only schedules a scan: Bun also delivers events for paths the scan already emitted,
102
+ // so adding payloads to the batch double-reports a save.
132
103
  #queue(abs: string): void {
133
104
  const kind = this.#classifier.classify(abs);
134
105
  if (!this.#index.primed) {
@@ -138,20 +109,18 @@ export class HmrWatcher {
138
109
  this.#scheduleFlush();
139
110
  return;
140
111
  }
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.
112
+ // An ignored path (a build's .akan/ burst) still marks a coalescing window that may hide a real save.
144
113
  if (kind === "ignore") {
145
114
  this.#scheduleVerify();
146
115
  return;
147
116
  }
148
- // Recorded only so `unreportedChanges` can tell which changes the events did name; it never decides
149
- // what is in a batch.
117
+ // Diagnostics only, for `unreportedChanges`; it never decides what is in a batch.
150
118
  this.#hinted.add(abs);
151
119
  this.#scheduleFlush();
152
120
  }
153
121
 
154
122
  #scheduleFlush(): void {
123
+ this.#windowStartedAt ??= Date.now();
155
124
  if (this.#flushing) return;
156
125
  if (this.#timer) clearTimeout(this.#timer);
157
126
  this.#timer = setTimeout(() => this.#flush(), this.#debounceMs);
@@ -168,13 +137,19 @@ export class HmrWatcher {
168
137
  try {
169
138
  while (!this.#stopped) {
170
139
  await this.#mergeDetectedChanges();
171
- if (this.#pending.size === 0) break;
140
+ if (this.#pending.size === 0) {
141
+ this.#windowStartedAt = null;
142
+ break;
143
+ }
172
144
  const files = Array.from(this.#pending.keys());
173
145
  const kinds = new Set(this.#pending.values());
146
+ const flushAt = Date.now();
147
+ const trace = { eventAt: this.#windowStartedAt ?? flushAt, flushAt };
148
+ this.#windowStartedAt = null;
174
149
  this.#pending.clear();
175
150
  this.#hinted.clear();
176
151
  try {
177
- await this.#onBatch({ files, kinds });
152
+ await this.#onBatch({ files, kinds, trace });
178
153
  } catch (e) {
179
154
  this.#logger.error(`[hmr] onBatch error: ${(e as Error).message}`);
180
155
  }
@@ -186,10 +161,6 @@ export class HmrWatcher {
186
161
  }
187
162
  }
188
163
 
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
164
  async #mergeDetectedChanges(): Promise<void> {
194
165
  const detected = await this.#index.collectChanges().catch((err) => {
195
166
  this.#logger.error(`[hmr] mtime scan failed: ${(err as Error).message}`);
@@ -205,8 +176,6 @@ export class HmrWatcher {
205
176
  }
206
177
  if (unreported === 0) return;
207
178
  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
179
  if (!this.#reportedCompensating) {
211
180
  this.#reportedCompensating = true;
212
181
  this.#logger.verbose(
@@ -218,13 +187,6 @@ export class HmrWatcher {
218
187
  );
219
188
  }
220
189
 
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
190
  #reportCoverageGaps(): void {
229
191
  const gaps = this.#index.coverageGaps;
230
192
  const key = gaps
@@ -247,10 +209,13 @@ export class HmrWatcher {
247
209
  );
248
210
  }
249
211
 
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
- */
212
+ static #envDebounceMs(): number | null {
213
+ const raw = process.env.AKAN_DEV_WATCH_DEBOUNCE_MS;
214
+ const ms = raw ? Number(raw) : Number.NaN;
215
+ return Number.isInteger(ms) && ms >= 0 ? ms : null;
216
+ }
217
+
218
+ // A write in the same window as a delivered event raises no event of its own, so one scan follows each window.
254
219
  #scheduleVerify(): void {
255
220
  if (this.#stopped || this.#verifyDelayMs <= 0) return;
256
221
  if (this.#verifyTimer) clearTimeout(this.#verifyTimer);
@@ -260,16 +225,8 @@ export class HmrWatcher {
260
225
  }, this.#verifyDelayMs);
261
226
  }
262
227
 
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
- */
228
+ // Terminates: build output lands in ignored .akan/ and codegen writes are content-guarded, so a rebuild moves no
229
+ // tracked mtime; only an unsettled directory schedules another scan.
273
230
  async #verify(): Promise<void> {
274
231
  if (this.#stopped || this.#flushing) return;
275
232
  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
  }