@akanjs/devkit 3.0.0-alpha.8 → 3.0.0-alpha.81

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 (172) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.ko.md +1 -1
  3. package/README.md +1 -1
  4. package/agentsIndex.test.ts +10 -0
  5. package/agentsIndex.ts +47 -1
  6. package/aiEditor.ts +1 -1
  7. package/akanApp/BackendImportGraph.test.ts +120 -0
  8. package/akanApp/BackendImportGraph.ts +167 -0
  9. package/akanApp/akanApp.host.test.ts +16 -678
  10. package/akanApp/akanApp.host.ts +65 -562
  11. package/akanApp/devHostPolicy.test.ts +542 -0
  12. package/akanApp/devHostPolicy.ts +412 -0
  13. package/akanApp/index.ts +2 -0
  14. package/akanConfig/akanConfig.test.ts +182 -14
  15. package/akanConfig/akanConfig.ts +132 -47
  16. package/akanConfig/types.ts +8 -0
  17. package/akanContext.ts +70 -14
  18. package/akanMcpContract.ts +25 -4
  19. package/applicationBuildRunner.test.ts +1 -1
  20. package/applicationBuildRunner.ts +45 -21
  21. package/artifact/implicitRootLayout.test.ts +67 -0
  22. package/artifact/implicitRootLayout.ts +27 -7
  23. package/artifact/routeSeedIndex.test.ts +1 -0
  24. package/biome.base.json +340 -0
  25. package/biomeBase.ts +9 -0
  26. package/capacitorApp.ts +10 -0
  27. package/cloud/globalConfig.ts +9 -2
  28. package/commandDecorators/command.ts +30 -6
  29. package/commandDecorators/commandBuilder.ts +20 -5
  30. package/commandDecorators/commandDecorators.test.ts +115 -3
  31. package/dependencyScanner.test.ts +99 -0
  32. package/dependencyScanner.ts +27 -24
  33. package/devkitUtils.test.ts +0 -104
  34. package/executors.test.ts +132 -5
  35. package/executors.ts +89 -14
  36. package/fileEditor.ts +19 -19
  37. package/formSetterScanner.test.ts +80 -0
  38. package/formSetterScanner.ts +92 -0
  39. package/frontendBuild/autoImportSync.test.ts +58 -0
  40. package/frontendBuild/autoImportSync.ts +4 -0
  41. package/frontendBuild/buildRouteClient.test.ts +47 -24
  42. package/frontendBuild/clientBuildTypes.ts +4 -0
  43. package/frontendBuild/clientEntriesBundler.ts +4 -1
  44. package/frontendBuild/clientEntryDiscovery.ts +2 -2
  45. package/frontendBuild/csrArtifactBuilder.ts +116 -84
  46. package/frontendBuild/cssCompiler.ts +123 -12
  47. package/frontendBuild/cssImportResolver.ts +8 -7
  48. package/frontendBuild/fontOptimizer.ts +37 -18
  49. package/frontendBuild/fontPruner.test.ts +220 -0
  50. package/frontendBuild/fontPruner.ts +206 -0
  51. package/frontendBuild/frontendBuild.test.ts +178 -10
  52. package/frontendBuild/hmrWatcher.ts +1 -1
  53. package/frontendBuild/index.ts +1 -1
  54. package/frontendBuild/pagesBundleBuilder.ts +3 -3
  55. package/frontendBuild/pagesEntrySourceGenerator.ts +11 -88
  56. package/frontendBuild/routeClientBuilder.ts +12 -5
  57. package/frontendBuild/ssrBaseArtifactBuilder.ts +21 -4
  58. package/frontendBuild/styleContract.ts +14 -20
  59. package/frontendBuild/themeValidator.ts +22 -17
  60. package/frontendBuild/vendorSpecifiers.ts +1 -0
  61. package/incrementalBuilder/devWatchBatch.test.ts +18 -20
  62. package/incrementalBuilder/devWatchBatch.ts +6 -2
  63. package/incrementalBuilder/incrementalBuilder.host.ts +1 -1
  64. package/incrementalBuilder/incrementalBuilder.proc.ts +8 -4
  65. package/index.ts +0 -5
  66. package/integration/devStabilityHarness.ts +2 -10
  67. package/libSource.test.ts +109 -0
  68. package/libSource.ts +126 -0
  69. package/lint/__fixtures__/README.md +40 -0
  70. package/lint/__fixtures__/no-arbitrary-color/bad.tsx +3 -0
  71. package/lint/__fixtures__/no-arbitrary-color/good.tsx +4 -0
  72. package/lint/__fixtures__/no-async-component-in-ui/bad.tsx +4 -0
  73. package/lint/__fixtures__/no-async-component-in-ui/good.tsx +4 -0
  74. package/lint/__fixtures__/no-bang-comment-in-client/bad.tsx +4 -0
  75. package/lint/__fixtures__/no-bang-comment-in-client/fixture.json +1 -0
  76. package/lint/__fixtures__/no-bang-comment-in-client/good.tsx +3 -0
  77. package/lint/__fixtures__/no-daisyui-legacy-class/bad.tsx +7 -0
  78. package/lint/__fixtures__/no-daisyui-legacy-class/good.tsx +5 -0
  79. package/lint/__fixtures__/no-deep-internal-import/bad.tsx +3 -0
  80. package/lint/__fixtures__/no-deep-internal-import/fixture.json +1 -0
  81. package/lint/__fixtures__/no-deep-internal-import/good.tsx +3 -0
  82. package/lint/__fixtures__/no-deprecated-log-level/bad.tsx +4 -0
  83. package/lint/__fixtures__/no-deprecated-log-level/good.tsx +4 -0
  84. package/lint/__fixtures__/no-import-client-functions/bad.tsx +2 -0
  85. package/lint/__fixtures__/no-import-client-functions/good.tsx +4 -0
  86. package/lint/__fixtures__/no-import-client-in-server/bad.tsx +6 -0
  87. package/lint/__fixtures__/no-import-client-in-server/good.tsx +5 -0
  88. package/lint/__fixtures__/no-import-external-library/bad.tsx +4 -0
  89. package/lint/__fixtures__/no-import-external-library/good.tsx +5 -0
  90. package/lint/__fixtures__/no-import-server-in-client/bad.tsx +6 -0
  91. package/lint/__fixtures__/no-import-server-in-client/good.tsx +4 -0
  92. package/lint/__fixtures__/no-init-fetch-in-client/store-file/bad.ts +1 -0
  93. package/lint/__fixtures__/no-init-fetch-in-client/store-file/fixture.json +1 -0
  94. package/lint/__fixtures__/no-init-fetch-in-client/store-file/good.ts +1 -0
  95. package/lint/__fixtures__/no-init-fetch-in-client/use-client/bad.tsx +4 -0
  96. package/lint/__fixtures__/no-init-fetch-in-client/use-client/good.tsx +5 -0
  97. package/lint/__fixtures__/no-inline-color/bad.tsx +3 -0
  98. package/lint/__fixtures__/no-inline-color/good.tsx +3 -0
  99. package/lint/__fixtures__/no-interpolated-arbitrary-class/bad.tsx +3 -0
  100. package/lint/__fixtures__/no-interpolated-arbitrary-class/good.tsx +3 -0
  101. package/lint/__fixtures__/no-js-private-class-method/bad.tsx +3 -0
  102. package/lint/__fixtures__/no-js-private-class-method/good.tsx +3 -0
  103. package/lint/__fixtures__/no-model-type-in-util-zone/bad.tsx +3 -0
  104. package/lint/__fixtures__/no-model-type-in-util-zone/good.tsx +6 -0
  105. package/lint/__fixtures__/no-raw-palette-class/bad.tsx +6 -0
  106. package/lint/__fixtures__/no-raw-palette-class/good.tsx +8 -0
  107. package/lint/__fixtures__/no-redeclare-predefined-endpoint/bad.ts +10 -0
  108. package/lint/__fixtures__/no-redeclare-predefined-endpoint/fixture.json +1 -0
  109. package/lint/__fixtures__/no-redeclare-predefined-endpoint/good.ts +6 -0
  110. package/lint/__fixtures__/no-return-in-store-action/bad.tsx +2 -0
  111. package/lint/__fixtures__/no-return-in-store-action/good.tsx +6 -0
  112. package/lint/__fixtures__/no-throw-raw-error/bad.tsx +4 -0
  113. package/lint/__fixtures__/no-throw-raw-error/good.tsx +4 -0
  114. package/lint/__fixtures__/no-unpublished-form-setter/bad.tsx +3 -0
  115. package/lint/__fixtures__/no-unpublished-form-setter/good.tsx +5 -0
  116. package/lint/__fixtures__/no-use-client-in-server/bad.tsx +3 -0
  117. package/lint/__fixtures__/no-use-client-in-server/good.tsx +2 -0
  118. package/lint/__fixtures__/non-scalar-props-restricted/bad.tsx +5 -0
  119. package/lint/__fixtures__/non-scalar-props-restricted/good.tsx +4 -0
  120. package/lint/gritRules.test.ts +178 -0
  121. package/lint/no-arbitrary-color.grit +1 -1
  122. package/lint/no-async-component-in-ui.grit +35 -0
  123. package/lint/no-bang-comment-in-client.grit +23 -10
  124. package/lint/no-daisyui-legacy-class.grit +26 -9
  125. package/lint/no-deprecated-log-level.grit +17 -0
  126. package/lint/no-import-client-in-server.grit +48 -0
  127. package/lint/no-import-server-in-client.grit +45 -0
  128. package/lint/no-init-fetch-in-client.grit +47 -0
  129. package/lint/no-inline-color.grit +10 -8
  130. package/lint/no-interpolated-arbitrary-class.grit +3 -3
  131. package/lint/no-model-type-in-util-zone.grit +58 -0
  132. package/lint/no-raw-palette-class.grit +3 -3
  133. package/lint/no-unpublished-form-setter.grit +41 -0
  134. package/lint/non-scalar-props-restricted.grit +16 -7
  135. package/linter.test.ts +80 -0
  136. package/linter.ts +99 -25
  137. package/package.json +6 -6
  138. package/prompter.ts +9 -4
  139. package/qualityScanner.test.ts +116 -0
  140. package/qualityScanner.ts +89 -21
  141. package/recipeScanner.ts +4 -1
  142. package/repoIdentity.ts +42 -0
  143. package/scanInfo.ts +32 -26
  144. package/semver.test.ts +26 -0
  145. package/semver.ts +31 -0
  146. package/slicePlanner.test.ts +182 -0
  147. package/slicePlanner.ts +235 -0
  148. package/ssrScanner.test.ts +301 -0
  149. package/subspace.test.ts +418 -0
  150. package/subspace.ts +723 -0
  151. package/subspaceConfig.ts +76 -0
  152. package/transforms/asyncDefaultExportDetector.ts +103 -0
  153. package/transforms/barrelImportsPlugin.ts +1 -1
  154. package/transforms/externalizeFrameworkPlugin.ts +0 -1
  155. package/transforms/transforms.test.ts +5 -5
  156. package/transforms/tsconfigPackageResolver.test.ts +230 -0
  157. package/tsconfig.json +6 -2
  158. package/typeChecker.ts +1 -1
  159. package/types.ts +1 -0
  160. package/ui/ScrollList.tsx +6 -8
  161. package/uploadRelease.ts +2 -2
  162. package/workflow/executor.test.ts +146 -0
  163. package/workspaceLayout.test.ts +56 -4
  164. package/workspaceLayout.ts +49 -4
  165. package/builder.ts +0 -164
  166. package/extractDeps.ts +0 -86
  167. package/frontendBuild/styleGuard.test.ts +0 -165
  168. package/frontendBuild/styleGuard.ts +0 -322
  169. package/getCredentials.ts +0 -19
  170. package/getModelFileData.ts +0 -62
  171. package/src/capacitorApp.ts +0 -282
  172. package/streamAi.ts +0 -45
@@ -0,0 +1,412 @@
1
+ import path from "node:path";
2
+ import type {
3
+ BuilderMessage,
4
+ BuildPhase,
5
+ ChangeBatch,
6
+ DevBuildStatus,
7
+ DevChangePlan,
8
+ DevChangeRole,
9
+ } from "akanjs/server";
10
+ import type { IncrementalBuilderStatus } from "../incrementalBuilder";
11
+
12
+ const BACKEND_RECOVERY_MAX_ATTEMPTS = 5;
13
+
14
+ /**
15
+ * How many requests may wait for a builder that is coming back. Generous — a page load asks for
16
+ * several routes — but finite, so a builder that never returns cannot grow this without bound. Past
17
+ * it, requests are failed as they were before, which is the behaviour this limit falls back to.
18
+ */
19
+ const HELD_BUILDER_REQUEST_LIMIT = 64;
20
+
21
+ export const BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS = 30_000;
22
+
23
+ const BUILDER_TIGHT_RSS_REPORT_LIMIT = 2;
24
+
25
+ // Far enough above the ceiling that no purge would rescue it; recycle without waiting.
26
+ const BUILDER_RSS_HARD_MULTIPLE = 1.5;
27
+
28
+ // A sandbox between user turns pays for a watcher that is watching nothing change. Suspending build
29
+ // capacity after this long returns the builder's residency until the next edit or route request.
30
+ const DEV_IDLE_SUSPEND_MS = 300_000;
31
+
32
+ // A wake that immediately suspends again would flap around whatever woke it.
33
+ const DEV_IDLE_MIN_UPTIME_MS = 30_000;
34
+
35
+ export const SOURCE_EXTS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs"]);
36
+
37
+ /\.(css|scss|sass|less|json|svg|png|jpe?g|webp|gif|avif|ico|woff2?|ttf|otf|mp3|mp4|wav|html)$/i;
38
+ const SERVER_SUFFIXES = [".service.ts", ".document.ts"];
39
+
40
+ const SHARED_SUFFIXES = [".constant.ts", ".dictionary.ts", ".signal.ts"];
41
+
42
+ const RUNTIME_METADATA_BASENAMES = new Set(["dict.ts", "sig.ts", "useClient.ts", "useServer.ts"]);
43
+
44
+ export const shouldRestartBackendByDevPlan = (
45
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
46
+ ): boolean | null => {
47
+ if (!message.devPlan) return null;
48
+ if (message.devPlan.actions.includes("report-error")) return false;
49
+ if (message.devPlan.actions.includes("restart-builder")) return false;
50
+ return message.devPlan.actions.includes("restart-backend");
51
+ };
52
+
53
+ export const shouldRestartBuilderByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
54
+ message.devPlan?.actions.includes("restart-builder") ?? false;
55
+
56
+ /**
57
+ * A backend that keeps dying isn't going to heal by retrying the same code; after this many
58
+ * consecutive attempts the host idles and the next server-side edit triggers a fresh restart.
59
+ */
60
+ export const shouldAbandonBackendRecovery = (attempts: number, maxAttempts = BACKEND_RECOVERY_MAX_ATTEMPTS): boolean =>
61
+ attempts >= maxAttempts;
62
+
63
+ /** The gateway reports backend failures with `generation: -1`; the host assigns its own counter then. */
64
+ export const normalizeBackendReportedGeneration = (generation: number): number | undefined =>
65
+ generation >= 0 ? generation : undefined;
66
+
67
+ export const shouldRestartDevHostByDevPlan = (message: Extract<BuilderMessage, { type: "invalidate" }>): boolean =>
68
+ message.devPlan?.actions.includes("restart-dev-host") ?? message.kinds.includes("config");
69
+
70
+ export type BackendLifecycleState = "starting" | "ready" | "restart-pending" | "stopping" | "recovering" | "stopped";
71
+
72
+ export interface BackendRestartReason {
73
+ generation?: number;
74
+ files: string[];
75
+ roles: Extract<DevChangeRole, "server" | "shared" | "barrel" | "config">[];
76
+ }
77
+
78
+ const RESTART_ROLE_ORDER: BackendRestartReason["roles"] = ["server", "shared", "barrel", "config"];
79
+
80
+ const generationValue = (generation: number | undefined): number => generation ?? -1;
81
+
82
+ export const isLegacyBackendFallbackFile = (file: string, workspaceRoot: string): boolean => {
83
+ const abs = path.resolve(file);
84
+ const ext = path.extname(abs).toLowerCase();
85
+ if (!SOURCE_EXTS.has(ext)) return false;
86
+ const rel = path.relative(path.resolve(workspaceRoot), abs);
87
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return false;
88
+ const parts = rel.split(path.sep).filter(Boolean);
89
+ const [scope] = parts;
90
+ if (scope !== "apps" && scope !== "libs" && scope !== "pkgs") return false;
91
+
92
+ const base = path.basename(abs);
93
+ return (
94
+ parts.includes("srvkit") ||
95
+ parts.includes("common") ||
96
+ SERVER_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
97
+ SHARED_SUFFIXES.some((suffix) => base.endsWith(suffix)) ||
98
+ RUNTIME_METADATA_BASENAMES.has(base) ||
99
+ base === "main.ts" ||
100
+ base === "server.ts"
101
+ );
102
+ };
103
+
104
+ export const shouldMarkBuildPhaseRecovered = (
105
+ previousByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
106
+ status: DevBuildStatus,
107
+ ): boolean => {
108
+ const previous = previousByPhase.get(status.phase);
109
+ return Boolean(previous && status.ok && !previous.ok && generationValue(status.generation) >= previous.generation);
110
+ };
111
+
112
+ export const createBackendBuildStatus = ({
113
+ generation,
114
+ ok,
115
+ files = [],
116
+ message,
117
+ }: {
118
+ generation: number;
119
+ ok: boolean;
120
+ files?: string[];
121
+ message?: string;
122
+ }): DevBuildStatus => ({
123
+ generation,
124
+ phase: "backend",
125
+ ok,
126
+ files,
127
+ message,
128
+ });
129
+
130
+ export const backendRestartReasonFromMessage = (
131
+ message: Extract<BuilderMessage, { type: "invalidate" }>,
132
+ ): BackendRestartReason => {
133
+ const roleSet = new Set<BackendRestartReason["roles"][number]>();
134
+ for (const role of message.devPlan?.roles ?? []) {
135
+ if (role === "server" || role === "shared" || role === "barrel" || role === "config") roleSet.add(role);
136
+ }
137
+ return {
138
+ generation: message.devPlan?.generation ?? message.generation,
139
+ files: [...new Set(message.files)].sort(),
140
+ roles: RESTART_ROLE_ORDER.filter((role) => roleSet.has(role)),
141
+ };
142
+ };
143
+
144
+ export const mergeBackendRestartReasons = (
145
+ current: BackendRestartReason | null,
146
+ next: BackendRestartReason,
147
+ ): BackendRestartReason => ({
148
+ generation:
149
+ generationValue(next.generation) >= generationValue(current?.generation) ? next.generation : current?.generation,
150
+ files: [...new Set([...(current?.files ?? []), ...next.files])].sort(),
151
+ roles: RESTART_ROLE_ORDER.filter((role) => current?.roles.includes(role) || next.roles.includes(role)),
152
+ });
153
+
154
+ export const shouldReplaceLastGoodMessage = (
155
+ current:
156
+ | Extract<BuilderMessage, { type: "pages-updated" }>
157
+ | Extract<BuilderMessage, { type: "css-updated" }>
158
+ | undefined,
159
+ next: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
160
+ ): boolean => !current || generationValue(next.data.generation) >= generationValue(current.data.generation);
161
+
162
+ export const shouldQueueBuildStatusReplay = (backendReady: boolean, pendingReplayCount: number): boolean =>
163
+ !backendReady || pendingReplayCount > 0;
164
+
165
+ /**
166
+ * Recycling the builder/backend on a generation whose build already failed is guaranteed to strand
167
+ * the dev server: the rebooted builder hits the same compile error and exits before builder-ready.
168
+ * Failing phase statuses for a generation arrive over IPC before that generation's invalidate, so
169
+ * the host can check them here and defer the recycle until a healthy batch lands.
170
+ */
171
+ export const hasBuildFailureForGeneration = (
172
+ statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
173
+ generation: number | undefined,
174
+ ): boolean => {
175
+ if (typeof generation !== "number") return false;
176
+ for (const status of statusByPhase.values()) {
177
+ if (!status.ok && status.generation === generation) return true;
178
+ }
179
+ return false;
180
+ };
181
+
182
+ export type BuilderRssRecycleDecision = "unbounded" | "below-ceiling" | "build-failed" | "too-soon" | "recycle";
183
+
184
+ /**
185
+ * Whether an over-ceiling builder should be replaced now.
186
+ *
187
+ * `Bun.build` never returns its native arenas, so the builder's RSS only comes back when the process
188
+ * exits. Recycling it is therefore the only bound available — but it costs a boot build, so the two
189
+ * cases where a recycle cannot help are excluded: a generation whose build already failed (the
190
+ * replacement would hit the same compile error), and a recycle so soon after the last one that the
191
+ * ceiling is evidently unreachable for this app.
192
+ */
193
+ export const decideBuilderRssRecycle = ({
194
+ rssBytes,
195
+ ceilingBytes,
196
+ buildFailed,
197
+ msSinceLastRecycle,
198
+ minIntervalMs = BUILDER_MIN_RSS_RECYCLE_INTERVAL_MS,
199
+ }: {
200
+ rssBytes: number;
201
+ ceilingBytes: number | null;
202
+ buildFailed: boolean;
203
+ msSinceLastRecycle: number | null;
204
+ minIntervalMs?: number;
205
+ }): BuilderRssRecycleDecision => {
206
+ if (!ceilingBytes) return "unbounded";
207
+ if (rssBytes < ceilingBytes) return "below-ceiling";
208
+ if (buildFailed) return "build-failed";
209
+ if (msSinceLastRecycle !== null && msSinceLastRecycle < minIntervalMs) return "too-soon";
210
+ return "recycle";
211
+ };
212
+
213
+ export type BuilderRssSettleDecision = "recycle-now" | "wait-and-recheck";
214
+
215
+ /**
216
+ * Whether an armed recycle should wait out the allocator's purge window before committing. A builder
217
+ * far enough over the ceiling is not going to be rescued by a purge, so waiting there only delays a
218
+ * recycle that has to happen.
219
+ */
220
+ export const decideBuilderRssSettle = ({
221
+ rssBytes,
222
+ ceilingBytes,
223
+ hardMultiple = BUILDER_RSS_HARD_MULTIPLE,
224
+ }: {
225
+ rssBytes: number;
226
+ ceilingBytes: number;
227
+ hardMultiple?: number;
228
+ }): BuilderRssSettleDecision => (rssBytes >= ceilingBytes * hardMultiple ? "recycle-now" : "wait-and-recheck");
229
+
230
+ export type IdleSuspendDecision =
231
+ | "disabled"
232
+ | "already-suspended"
233
+ | "builder-not-ready"
234
+ | "backend-not-ready"
235
+ | "build-failed"
236
+ | "restart-pending"
237
+ | "too-soon"
238
+ | "suspend";
239
+
240
+ /**
241
+ * Whether the dev host may drop its build capacity now. Every "no" here is a case where suspending
242
+ * would either lose work or produce a wake that immediately re-suspends:
243
+ *
244
+ * - a red build means the developer is mid-fix and about to save again, and a wake would boot straight
245
+ * back into the same error via the degraded-boot path
246
+ * - a pending restart/recovery already has its own plan for the builder
247
+ * - `too-soon` keeps a wake from flapping around whatever triggered it
248
+ */
249
+ export const decideIdleSuspend = ({
250
+ enabled,
251
+ suspended,
252
+ builderReady,
253
+ backendReady,
254
+ buildFailed,
255
+ restartPending,
256
+ msSinceWake,
257
+ minUptimeMs = DEV_IDLE_MIN_UPTIME_MS,
258
+ }: {
259
+ enabled: boolean;
260
+ suspended: boolean;
261
+ builderReady: boolean;
262
+ backendReady: boolean;
263
+ buildFailed: boolean;
264
+ restartPending: boolean;
265
+ msSinceWake: number | null;
266
+ minUptimeMs?: number;
267
+ }): IdleSuspendDecision => {
268
+ if (!enabled) return "disabled";
269
+ if (suspended) return "already-suspended";
270
+ if (!builderReady) return "builder-not-ready";
271
+ if (!backendReady) return "backend-not-ready";
272
+ if (buildFailed) return "build-failed";
273
+ if (restartPending) return "restart-pending";
274
+ if (msSinceWake !== null && msSinceWake < minUptimeMs) return "too-soon";
275
+ return "suspend";
276
+ };
277
+
278
+ /** `undefined` env means the default is on; any non-positive value turns idle suspend off. */
279
+ export const resolveIdleSuspendMs = (raw: string | undefined): number | null => {
280
+ if (raw === undefined || raw === "") return DEV_IDLE_SUSPEND_MS;
281
+ const parsed = Number(raw);
282
+ if (!Number.isFinite(parsed) || parsed <= 0) return null;
283
+ return Math.round(parsed);
284
+ };
285
+
286
+ /** Any red phase blocks a suspend, unlike the rss recycle which only cares about one generation. */
287
+ export const hasAnyBuildFailure = (statusByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>): boolean =>
288
+ [...statusByPhase.values()].some((status) => !status.ok);
289
+
290
+ /**
291
+ * A config change while suspended cannot be applied by restarting the builder alone — the dev host
292
+ * itself has to re-read the config, which is the same path an ordinary config save takes.
293
+ */
294
+ export const shouldRefreshConfigOnIdleWake = (batch: ChangeBatch | null): boolean =>
295
+ !!batch && batch.kinds.has("config");
296
+
297
+ /**
298
+ * Whether a request that arrived while the builder was away should wait for the one coming back.
299
+ *
300
+ * A recycle or a crash-restart is a gap, not a failure — the request that lands in it is the page a
301
+ * developer is waiting on. A stopped builder is a different thing: nothing is bringing it back, so
302
+ * waiting would only delay the error.
303
+ *
304
+ * `recycling` is in here for the same reason as `restarting`, and was the hole this decision shipped
305
+ * with: a draining builder is still alive, so the request reached it and came back refused while the
306
+ * host still thought there was nothing to wait for.
307
+ */
308
+ const RETURNING_BUILDER_STATUSES = new Set<IncrementalBuilderStatus>(["starting", "recycling", "restarting"]);
309
+ export const shouldHoldForReturningBuilder = ({
310
+ status,
311
+ heldCount,
312
+ limit = HELD_BUILDER_REQUEST_LIMIT,
313
+ }: {
314
+ status: IncrementalBuilderStatus;
315
+ heldCount: number;
316
+ limit?: number;
317
+ }): boolean => RETURNING_BUILDER_STATUSES.has(status) && heldCount < limit;
318
+
319
+ /**
320
+ * Whether a builder that is over the ceiling again this soon after being replaced is worth saying so
321
+ * about, once. Not a reason to stop enforcing the ceiling: the minimum interval already bounds what
322
+ * this costs at one recycle per interval, and dropping the bound is how a container gets OOM-killed.
323
+ *
324
+ * This used to disable the ceiling for the session, on a count that a single page load reaches — two
325
+ * route builds, two reports, both inside the interval. That is normal work on any app whose builds sit
326
+ * above the ceiling, which is the same app the ceiling was derived for.
327
+ */
328
+ export const shouldWarnBuilderRssCeilingTight = (
329
+ reportsSinceRecycle: number,
330
+ limit = BUILDER_TIGHT_RSS_REPORT_LIMIT,
331
+ ): boolean => reportsSinceRecycle >= limit;
332
+
333
+ /**
334
+ * Whether recycling can ever bring this builder under the ceiling.
335
+ *
336
+ * Measured on a replacement the moment it is ready, before it has built anything on demand: that is
337
+ * the floor every future replacement lands on, so a floor already over the ceiling is the one case
338
+ * where the recycle loop is pure cost. It is also the case the escape hatch was always described as
339
+ * being for — the previous rule inferred it from report timing and caught ordinary work instead.
340
+ */
341
+ export const isRssCeilingUnreachable = (freshRssBytes: number | null, ceilingBytes: number | null): boolean =>
342
+ freshRssBytes !== null && ceilingBytes !== null && freshRssBytes >= ceilingBytes;
343
+
344
+ /**
345
+ * Whether a recycled builder's re-announced boot artifact actually differs from what the backend
346
+ * already has. Both payload identities are content hashes — `pages-[hash].js` and
347
+ * `<name>-[hash].css` — so an unchanged recycle produces identical ones and needs no reload. Only a
348
+ * save that raced the recycle moves them, and that is the case worth pushing.
349
+ */
350
+ export const shouldRelayRecycledFrontendState = (
351
+ current:
352
+ | Extract<BuilderMessage, { type: "pages-updated" }>
353
+ | Extract<BuilderMessage, { type: "css-updated" }>
354
+ | undefined,
355
+ next: Extract<BuilderMessage, { type: "pages-updated" }> | Extract<BuilderMessage, { type: "css-updated" }>,
356
+ ): boolean => {
357
+ if (!current || current.type !== next.type) return true;
358
+ if (current.type === "pages-updated" && next.type === "pages-updated")
359
+ return current.data.bundlePath !== next.data.bundlePath;
360
+ if (current.type === "css-updated" && next.type === "css-updated")
361
+ return JSON.stringify(current.data.cssAssets) !== JSON.stringify(next.data.cssAssets);
362
+ return true;
363
+ };
364
+
365
+ const mergeDevPlans = (current?: DevChangePlan, next?: DevChangePlan): DevChangePlan | undefined => {
366
+ if (!current) return next;
367
+ if (!next) return current;
368
+ const reasonByFile: Record<string, string[]> = { ...current.reasonByFile };
369
+ for (const [file, reasons] of Object.entries(next.reasonByFile)) {
370
+ reasonByFile[file] = [...new Set([...(reasonByFile[file] ?? []), ...reasons])].sort();
371
+ }
372
+ return {
373
+ generation: Math.max(current.generation, next.generation),
374
+ files: [...new Set([...current.files, ...next.files])].sort(),
375
+ generatedFiles: [...new Set([...current.generatedFiles, ...next.generatedFiles])].sort(),
376
+ roles: [...new Set([...current.roles, ...next.roles])].sort(),
377
+ actions: [...new Set([...current.actions, ...next.actions])].sort(),
378
+ reasonByFile,
379
+ };
380
+ };
381
+
382
+ /** A deferred recycle accumulates every batch it skipped so the eventual restart covers them all. */
383
+ export const mergeInvalidateMessages = (
384
+ current: Extract<BuilderMessage, { type: "invalidate" }>,
385
+ next: Extract<BuilderMessage, { type: "invalidate" }>,
386
+ ): Extract<BuilderMessage, { type: "invalidate" }> => {
387
+ const generation = Math.max(generationValue(current.generation), generationValue(next.generation));
388
+ return {
389
+ type: "invalidate",
390
+ kinds: [...new Set([...current.kinds, ...next.kinds])].sort(),
391
+ files: [...new Set([...current.files, ...next.files])].sort(),
392
+ generation: generation >= 0 ? generation : undefined,
393
+ devPlan: mergeDevPlans(current.devPlan, next.devPlan),
394
+ };
395
+ };
396
+
397
+ export const buildStatusReplaySequence = (
398
+ pendingReplay: readonly DevBuildStatus[],
399
+ latestByPhase: ReadonlyMap<BuildPhase, DevBuildStatus>,
400
+ ): DevBuildStatus[] => [...pendingReplay, ...latestByPhase.values()];
401
+
402
+ /** `(mtimeMs, size)` per file — what a save moves, and what a rebuild of identical content does not. */
403
+ export type SourceFingerprints = ReadonlyMap<string, string>;
404
+
405
+ /**
406
+ * Which of the files in `before` are no longer stamped the way they were.
407
+ *
408
+ * Only files present in `before` are compared. The question this answers is which *running* code went
409
+ * stale while nothing was watching, and a file that did not exist then is not running anywhere.
410
+ */
411
+ export const filesChangedSince = (before: SourceFingerprints, after: SourceFingerprints): string[] =>
412
+ [...before].filter(([file, stamp]) => after.get(file) !== stamp).map(([file]) => file);
package/akanApp/index.ts CHANGED
@@ -1 +1,3 @@
1
1
  export * from "./akanApp.host";
2
+ export * from "./BackendImportGraph";
3
+ export * from "./devHostPolicy";
@@ -3,6 +3,7 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
+ import { AppExecutor, WorkspaceExecutor } from "../executors";
6
7
  import type { PackageJson } from "../types";
7
8
  import { AkanAppConfig, AkanLibConfig, deriveDefaultAppId } from "./akanConfig";
8
9
  import type { DeepPartial, LibConfigResult } from "./types";
@@ -19,7 +20,6 @@ const packageJson: PackageJson = {
19
20
  react: "19.0.0",
20
21
  "react-dom": "19.0.0",
21
22
  "react-server-dom-webpack": "19.0.0",
22
- sharp: "1.0.0",
23
23
  "@external/runtime": "2.0.0",
24
24
  },
25
25
  };
@@ -64,7 +64,8 @@ describe("AkanAppConfig", () => {
64
64
  expect(config.barrelImports).toEqual(
65
65
  expect.arrayContaining(["@apps/portal/ui", "@libs/shared/server", "akanjs/common", "akanjs/server"]),
66
66
  );
67
- expect(config.docker.content).toContain("ENV AKAN_PUBLIC_APP_NAME=portal");
67
+ expect(config.dockerfile).toContain("ENV AKAN_PUBLIC_APP_NAME=portal");
68
+ expect(config.dockerfile).toContain("ENV AKAN_LOG_TO_FILE=0");
68
69
  expect(process.env.AKAN_PUBLIC_DEFAULT_LOCALE).toBe("en");
69
70
  });
70
71
 
@@ -128,9 +129,67 @@ describe("AkanAppConfig", () => {
128
129
  });
129
130
  expect(config.publicEnv).toEqual(["AKAN_PUBLIC_FEATURE"]);
130
131
  expect(config.optimizeImports).toContain("custom-icons");
131
- expect(config.docker.content).toContain('CMD ["bun","server.js"]');
132
- expect(config.docker.content).toContain("FROM oven/bun:amd64 AS amd64");
133
- expect(config.docker.content).toContain('RUN if [ "$TARGETARCH" = "arm64"');
132
+ expect(config.dockerfile).toContain('CMD ["bun","server.js"]');
133
+ expect(config.dockerfile).toContain("FROM oven/bun:amd64 AS amd64");
134
+ expect(config.dockerfile).toContain('RUN if [ "$TARGETARCH" = "arm64"');
135
+ });
136
+
137
+ test("defaults both web surfaces on and keeps the image free of web env overrides", () => {
138
+ const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
139
+
140
+ expect(config.web).toEqual({ ssr: true, csr: true });
141
+ expect(config.dockerfile).not.toContain("AKAN_SSR");
142
+ expect(config.dockerfile).not.toContain("AKAN_CSR");
143
+ });
144
+
145
+ test("bakes the disabled surface into the image env so the default matches what was built", () => {
146
+ const ssrOnly = new AkanAppConfig(app, [], packageJson, { web: { csr: false } }, baseDevEnv);
147
+ expect(ssrOnly.web).toEqual({ ssr: true, csr: false });
148
+ expect(ssrOnly.dockerfile).toContain("ENV AKAN_CSR=false");
149
+ expect(ssrOnly.dockerfile).not.toContain("ENV AKAN_SSR=false");
150
+
151
+ const apiOnly = new AkanAppConfig(app, [], packageJson, { web: false }, baseDevEnv);
152
+ expect(apiOnly.web).toEqual({ ssr: false, csr: false });
153
+ expect(apiOnly.dockerfile).toContain("ENV AKAN_SSR=false");
154
+ expect(apiOnly.dockerfile).toContain("ENV AKAN_CSR=false");
155
+
156
+ expect(new AkanAppConfig(app, [], packageJson, { web: true }, baseDevEnv).web).toEqual({ ssr: true, csr: true });
157
+ });
158
+
159
+ test("refuses a csr-less build that ships a mobile app", () => {
160
+ expect(
161
+ () => new AkanAppConfig(app, [], packageJson, { web: { csr: false }, mobile: { appName: "portal" } }, baseDevEnv),
162
+ ).toThrow("the Capacitor build ships that bundle");
163
+ });
164
+
165
+ test("installs only ca-certificates and tzdata in the default image", () => {
166
+ const config = new AkanAppConfig(app, [], packageJson, {}, baseDevEnv);
167
+
168
+ expect(config.dockerfile).toContain(
169
+ "RUN apt-get update && apt-get upgrade -y && apt-get install -y --no-install-recommends ca-certificates tzdata && rm -rf /var/lib/apt/lists/*",
170
+ );
171
+ // The Chromium/ffmpeg toolchain moved to per-app `preRuns`; keeping it here paid for it in every image.
172
+ for (const dropped of ["libnss3", "ffmpeg", "build-essential", "redis", "xdg-utils"])
173
+ expect(config.dockerfile).not.toContain(dropped);
174
+ });
175
+
176
+ test("keeps a declared Dockerfile string verbatim", () => {
177
+ const dockerfile = 'FROM oven/bun:1-slim\nCOPY . .\nCMD ["bun","main.js"]';
178
+ const config = new AkanAppConfig(app, [], packageJson, { docker: dockerfile }, baseDevEnv);
179
+
180
+ expect(config.docker).toBe(dockerfile);
181
+ expect(config.dockerfile).toBe(dockerfile);
182
+ });
183
+
184
+ test("resolves the image parts, defaulting the base image and the command", () => {
185
+ const config = new AkanAppConfig(app, [], packageJson, { docker: { preRuns: ["echo hi"] } }, baseDevEnv);
186
+
187
+ expect(config.docker).toEqual({
188
+ image: "oven/bun:1-slim",
189
+ preRuns: ["echo hi"],
190
+ postRuns: [],
191
+ command: ["bun", "main.js"],
192
+ });
134
193
  });
135
194
 
136
195
  test("creates production package json and reports missing external versions", () => {
@@ -144,8 +203,6 @@ describe("AkanAppConfig", () => {
144
203
  react: "19.0.0",
145
204
  "react-dom": "19.0.0",
146
205
  "react-server-dom-webpack": "19.0.0",
147
- croner: akanPackageJson.peerDependencies?.croner,
148
- sharp: "1.0.0",
149
206
  "@external/runtime": "2.0.0",
150
207
  },
151
208
  });
@@ -184,8 +241,6 @@ describe("AkanAppConfig", () => {
184
241
  react: runtimeDependencies.react,
185
242
  "react-dom": runtimeDependencies["react-dom"],
186
243
  "react-server-dom-webpack": runtimeDependencies["react-server-dom-webpack"],
187
- croner: runtimeDependencies.croner,
188
- sharp: runtimeDependencies.sharp,
189
244
  });
190
245
  });
191
246
 
@@ -198,9 +253,6 @@ describe("AkanAppConfig", () => {
198
253
  const multipleConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "multiple" }, baseDevEnv);
199
254
  const clusterConfig = new AkanAppConfig(app, [], packageJson, { defaultDatabaseMode: "cluster" }, baseDevEnv);
200
255
 
201
- expect(singleConfig.getProductionPackageJson().dependencies).toMatchObject({
202
- croner: runtimeDependencies.croner,
203
- });
204
256
  expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("ioredis");
205
257
  expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("bullmq");
206
258
  expect(singleConfig.getProductionPackageJson().dependencies).not.toHaveProperty("@libsql/client");
@@ -210,7 +262,6 @@ describe("AkanAppConfig", () => {
210
262
  expect(multipleConfig.getProductionPackageJson().dependencies).toMatchObject({
211
263
  "@libsql/client": runtimeDependencies["@libsql/client"],
212
264
  bullmq: runtimeDependencies.bullmq,
213
- croner: runtimeDependencies.croner,
214
265
  ioredis: runtimeDependencies.ioredis,
215
266
  protobufjs: runtimeDependencies.protobufjs,
216
267
  });
@@ -218,7 +269,6 @@ describe("AkanAppConfig", () => {
218
269
 
219
270
  expect(clusterConfig.getProductionPackageJson().dependencies).toMatchObject({
220
271
  bullmq: runtimeDependencies.bullmq,
221
- croner: runtimeDependencies.croner,
222
272
  ioredis: runtimeDependencies.ioredis,
223
273
  postgres: runtimeDependencies.postgres,
224
274
  protobufjs: runtimeDependencies.protobufjs,
@@ -392,6 +442,115 @@ describe("deriveDefaultAppId", () => {
392
442
  });
393
443
  });
394
444
 
445
+ describe("AkanAppConfig lib externalLibs", () => {
446
+ test("merges lib-declared external libs into the app's own, deduped", () => {
447
+ const libAwarePackageJson: PackageJson = {
448
+ ...packageJson,
449
+ dependencies: { ...packageJson.dependencies, puppeteer: "24.0.0" },
450
+ };
451
+ const config = new AkanAppConfig(
452
+ app,
453
+ ["shared"],
454
+ libAwarePackageJson,
455
+ { externalLibs: ["@external/runtime"] },
456
+ baseDevEnv,
457
+ [],
458
+ { externalLibs: ["@external/runtime", "puppeteer"], docker: { preRuns: [], postRuns: [] } },
459
+ );
460
+
461
+ expect(config.externalLibs).toEqual(["@external/runtime", "puppeteer"]);
462
+ expect(config.getProductionPackageJson().dependencies).toMatchObject({
463
+ "@external/runtime": "2.0.0",
464
+ puppeteer: "24.0.0",
465
+ });
466
+ });
467
+
468
+ test("reads them off every workspace lib config on load", async () => {
469
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-libext-"));
470
+ try {
471
+ fs.mkdirSync(path.join(root, "apps/extapp"), { recursive: true });
472
+ fs.mkdirSync(path.join(root, "libs/extlib"), { recursive: true });
473
+ fs.writeFileSync(path.join(root, "apps/extapp/akan.config.ts"), "export default { externalLibs: ['shiki'] };\n");
474
+ fs.writeFileSync(
475
+ path.join(root, "libs/extlib/akan.config.ts"),
476
+ "export default { externalLibs: ['puppeteer'] };\n",
477
+ );
478
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "extrepo", version: "1.0.0" }));
479
+ fs.writeFileSync(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=extrepo\nAKAN_PUBLIC_SERVE_DOMAIN=ext.test\n");
480
+
481
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "extrepo" });
482
+ const config = await AppExecutor.from(workspace, "extapp").getConfig();
483
+
484
+ expect(config.externalLibs).toEqual(["shiki", "puppeteer"]);
485
+ } finally {
486
+ fs.rmSync(root, { recursive: true, force: true });
487
+ }
488
+ });
489
+ });
490
+
491
+ describe("AkanAppConfig lib docker runs", () => {
492
+ const libDocker = (preRuns: string[], postRuns: string[] = []) => ({
493
+ externalLibs: [],
494
+ docker: { preRuns, postRuns },
495
+ });
496
+
497
+ test("runs lib steps before the app's own, deduped", () => {
498
+ const config = new AkanAppConfig(
499
+ app,
500
+ ["shared"],
501
+ packageJson,
502
+ { docker: { preRuns: ["apt-get install -y ffmpeg", "echo app"], postRuns: ["echo app-post"] } },
503
+ baseDevEnv,
504
+ [],
505
+ libDocker(["apt-get install -y ffmpeg", "echo lib"], ["echo lib-post"]),
506
+ );
507
+
508
+ expect(config.docker).toMatchObject({
509
+ preRuns: ["apt-get install -y ffmpeg", "echo lib", "echo app"],
510
+ postRuns: ["echo lib-post", "echo app-post"],
511
+ });
512
+ expect(config.dockerfile).toContain("RUN echo lib\nRUN echo app\n");
513
+ expect(config.dockerfile.match(/RUN apt-get install -y ffmpeg/g)).toHaveLength(1);
514
+ });
515
+
516
+ test("drops them when the app hands over a whole Dockerfile", () => {
517
+ const config = new AkanAppConfig(
518
+ app,
519
+ ["shared"],
520
+ packageJson,
521
+ { docker: "FROM scratch" },
522
+ baseDevEnv,
523
+ [],
524
+ libDocker(["echo lib"]),
525
+ );
526
+
527
+ expect(config.dockerfile).toBe("FROM scratch");
528
+ });
529
+
530
+ test("reads them off every workspace lib config on load", async () => {
531
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "akan-config-libdocker-"));
532
+ try {
533
+ fs.mkdirSync(path.join(root, "apps/extapp"), { recursive: true });
534
+ fs.mkdirSync(path.join(root, "libs/extlib"), { recursive: true });
535
+ fs.writeFileSync(path.join(root, "apps/extapp/akan.config.ts"), "export default {};\n");
536
+ fs.writeFileSync(
537
+ path.join(root, "libs/extlib/akan.config.ts"),
538
+ "export default { docker: { preRuns: ['echo from-lib'], postRuns: [{ arm64: 'echo arm-only' }] } };\n",
539
+ );
540
+ fs.writeFileSync(path.join(root, "package.json"), JSON.stringify({ name: "extrepo", version: "1.0.0" }));
541
+ fs.writeFileSync(path.join(root, ".env"), "AKAN_PUBLIC_REPO_NAME=extrepo\nAKAN_PUBLIC_SERVE_DOMAIN=ext.test\n");
542
+
543
+ const workspace = WorkspaceExecutor.fromRoot({ workspaceRoot: root, repoName: "extrepo" });
544
+ const config = await AppExecutor.from(workspace, "extapp").getConfig();
545
+
546
+ expect(config.dockerfile).toContain("RUN echo from-lib");
547
+ expect(config.dockerfile).toContain('RUN if [ "$TARGETARCH" = "arm64"');
548
+ } finally {
549
+ fs.rmSync(root, { recursive: true, force: true });
550
+ }
551
+ });
552
+ });
553
+
395
554
  describe("AkanLibConfig", () => {
396
555
  test("uses empty external libs by default and preserves explicit libs", () => {
397
556
  const lib = { name: "shared" } as never;
@@ -402,6 +561,15 @@ describe("AkanLibConfig", () => {
402
561
  };
403
562
  expect(new AkanLibConfig(lib, config).externalLibs).toEqual(["firebase-admin"]);
404
563
  });
564
+
565
+ test("defaults docker runs to empty lists and preserves declared ones", () => {
566
+ const lib = { name: "shared" } as never;
567
+ expect(new AkanLibConfig(lib, {}).docker).toEqual({ preRuns: [], postRuns: [] });
568
+ expect(new AkanLibConfig(lib, { docker: { preRuns: ["echo lib"] } }).docker).toEqual({
569
+ preRuns: ["echo lib"],
570
+ postRuns: [],
571
+ });
572
+ });
405
573
  });
406
574
 
407
575
  describe("AkanAppConfig.importConfigModule", () => {