@akanjs/devkit 2.4.1-rc.7 → 2.4.2-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/DEV_RUNTIME_KNOBS.md +97 -0
  3. package/README.md +6 -0
  4. package/akanApp/akanApp.host.test.ts +75 -6
  5. package/akanApp/akanApp.host.ts +303 -25
  6. package/akanConfig/akanConfig.ts +3 -0
  7. package/applicationBuildRunner.ts +8 -0
  8. package/commandDecorators/command.ts +16 -1
  9. package/executors.test.ts +290 -1
  10. package/executors.ts +210 -59
  11. package/fileSys.ts +7 -1
  12. package/frontendBuild/clientEntryDiscovery.ts +91 -53
  13. package/frontendBuild/cssCandidateCache.ts +109 -0
  14. package/frontendBuild/cssCompiler.ts +70 -18
  15. package/frontendBuild/fontOptimizer.ts +10 -2
  16. package/frontendBuild/frontendBuild.test.ts +3 -0
  17. package/frontendBuild/hmrWatcher.ts +6 -0
  18. package/frontendBuild/sourceMtimeIndex.test.ts +51 -2
  19. package/frontendBuild/sourceMtimeIndex.ts +66 -5
  20. package/incrementalBuilder/buildBatchRunner.ts +10 -1
  21. package/incrementalBuilder/builderChannel.test.ts +16 -7
  22. package/incrementalBuilder/builderRequestRouter.test.ts +89 -0
  23. package/incrementalBuilder/builderRequestRouter.ts +66 -0
  24. package/incrementalBuilder/incrementalBuilder.host.test.ts +28 -1
  25. package/incrementalBuilder/incrementalBuilder.host.ts +24 -2
  26. package/incrementalBuilder/incrementalBuilder.proc.ts +36 -26
  27. package/integration/devResourceProbe.ts +319 -0
  28. package/integration/devStability.integration.test.ts +153 -4
  29. package/integration/devStabilityHarness.ts +30 -5
  30. package/package.json +2 -2
  31. package/packageExportsMap.ts +1 -1
  32. package/routeSourceValidator.ts +51 -1
  33. package/scanInfo.ts +4 -0
  34. package/transforms/barrelImportsPlugin.ts +20 -13
package/CHANGELOG.md CHANGED
@@ -1,5 +1,35 @@
1
1
  # @akanjs/devkit
2
2
 
3
+ ## 2.4.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [473be34]
8
+ - Updated dependencies [f5bfa27]
9
+ - Updated dependencies [473be34]
10
+ - Updated dependencies [068158b]
11
+ - Updated dependencies [46a1a4a]
12
+ - Updated dependencies [90c6597]
13
+ - Updated dependencies [aca901d]
14
+ - Updated dependencies [068158b]
15
+ - Updated dependencies [cb895b7]
16
+ - Updated dependencies [f8a9bc5]
17
+ - Updated dependencies [d973712]
18
+ - Updated dependencies [068158b]
19
+ - Updated dependencies [cc3dd40]
20
+ - Updated dependencies [8a2b795]
21
+ - Updated dependencies [068158b]
22
+ - Updated dependencies [473be34]
23
+ - Updated dependencies [e5fde3b]
24
+ - Updated dependencies [473be34]
25
+ - Updated dependencies [f28466f]
26
+ - Updated dependencies [51851fa]
27
+ - Updated dependencies [1c3436f]
28
+ - Updated dependencies [128e9a3]
29
+ - Updated dependencies [a5d4a8a]
30
+ - Updated dependencies [473be34]
31
+ - akanjs@2.4.1
32
+
3
33
  ## 2.4.0
4
34
 
5
35
  ### Minor Changes
@@ -0,0 +1,97 @@
1
+ # Dev runtime knobs
2
+
3
+ Every environment variable that changes how much memory a dev server is allowed to use, and how it
4
+ gives that memory back. Written for whoever sizes a sandbox: the defaults are tuned for a laptop, and a
5
+ container smaller than one needs to know which numbers move.
6
+
7
+ Nothing here is a secret — these are sizing knobs, set alongside the rest of a deployment's env.
8
+
9
+ ## The budget
10
+
11
+ | variable | default | what it does |
12
+ |---|---|---|
13
+ | `AKAN_MEMORY_LIMIT` | the container's cgroup `memory.max`, if any | The total the dev server may use. Accepts a plain byte count or a suffix — `1200mb`, `2gib`. Every ceiling below is a fraction of this. |
14
+
15
+ With neither an explicit value nor a cgroup limit, each process falls back to its own dev default, which
16
+ assumes a developer laptop rather than a sandbox.
17
+
18
+ ## Per-process ceilings
19
+
20
+ Each ceiling can be set outright, and otherwise takes a share of `AKAN_MEMORY_LIMIT`. A process that
21
+ crosses its ceiling is replaced when it is next idle — never mid-work.
22
+
23
+ | process | explicit (MiB) | explicit (bytes) | share of the limit | fallback with no limit |
24
+ |---|---|---|---|---|
25
+ | incremental builder | `AKAN_BUILDER_MAX_RSS_MB` | `AKAN_BUILDER_MAX_RSS` | **0.35** | 1200MB (dev) |
26
+ | RSC worker | `AKAN_RSC_WORKER_MAX_RSS_MB` | `AKAN_RSC_WORKER_MAX_RSS` | **0.55** | 768MB (dev), unbounded (production) |
27
+
28
+ `AKAN_BUILDER_MAX_RSS_MB=0` leaves the builder unbounded, which is the escape hatch for an app whose
29
+ boot build simply does not fit under the derived share.
30
+
31
+ ### The shares do not add up to a budget, and that is worth knowing
32
+
33
+ 0.35 + 0.55 = **0.90 of the declared limit**, and only two processes are in that sum. A dev server also
34
+ runs a dev host, a gateway, one or more backend replicas, and — during a build — a disposable build
35
+ worker whose peak is the largest transient in the tree (measured: ~548MB on a mid-size app, ~1.1GB on
36
+ this repo's own `apps/akan`). None of those has a ceiling, and none is subtracted from the two above.
37
+
38
+ In practice this holds because the two ceilings are rarely at their limit simultaneously and the build
39
+ worker exits. But if you are sizing a container to a hard number, size it against the *sum of observed
40
+ peaks*, not against these fractions. Measured floors, for reference: builder 134-202MB at rest and
41
+ ~490MB after a route build, RSC worker ~142MB after boot, ~247MB per route build.
42
+
43
+ ## Recycling behaviour
44
+
45
+ | variable | default | what it does |
46
+ |---|---|---|
47
+ | `AKAN_DEV_IDLE_SUSPEND_MS` | `300000` (5 min) | How long a dev server may sit unused before its builder is released entirely. The next edit or route request brings it back, at the cost of one boot build. `0` keeps the builder resident for the whole session. |
48
+ | `AKAN_RSC_WORKER_MAX_RELOADS` | `10` in dev, off in production | Reloads tolerated before the worker is recycled instead of reloaded in place. Bun's ESM registry never evicts, so each in-place reload of the pages bundle is retained. |
49
+ | `AKAN_RSC_WORKER_MIN_RECYCLE_INTERVAL_MS` | `1000` | Floor between worker recycles, so a burst of saves produces one. |
50
+ | `AKAN_RSC_WORKER_RECYCLE_GRACE_MS` | `5000` | How long a recycled worker may take to finish what it is holding. |
51
+ | `AKAN_RSC_WORKER_MAX_RENDER_COUNT` | unset | Recycle after this many renders. Off by default; a blunt instrument for chasing a leak. |
52
+ | `AKAN_RSC_WORKER_MAX_ROUTE_MODULES` | unset | Recycle once this many route modules are loaded. Same. |
53
+
54
+ The builder's own recycle timing is not configurable and is stated here because it is what a tight
55
+ ceiling costs: it waits **750ms** of quiet so a recycle never lands mid-burst, then re-reads the
56
+ process's RSS after **20s** (unless it is already 1.5× over) to see whether the allocator gave the
57
+ memory back on its own, and never recycles twice inside **30s**. Requests that arrive while the builder
58
+ is away are held — up to **64** of them — and replayed when it is back, rather than failed.
59
+
60
+ If the builder crosses the ceiling again within that 30s window, the dev host says so once and keeps
61
+ enforcing the ceiling. It stops enforcing only when a *freshly replaced* builder is already over it,
62
+ which is the one case where replacing it again cannot help; it says that too, and names this knob.
63
+
64
+ ## Build behaviour
65
+
66
+ | variable | default | what it does |
67
+ |---|---|---|
68
+ | `AKAN_DEV_CSR_REBUILD` | off | Rebuild the CSR artifact on every save. Armed automatically by the first `/__csr` or `?csr=true` request, which is what a mobile WebView session does — set it explicitly only to have it from boot. |
69
+ | `AKAN_BUILDER_RPC_TIMEOUT_MS` | `120000` | How long the backend waits for a builder answer. Generous on purpose: a cold CSR build of every page legitimately takes tens of seconds. |
70
+ | `AKAN_SERVER_PAGES_SPLITTING` | off | Emit the server pages bundle as chunks instead of one file. Experimental — the memory/latency trade has not been measured on a real app. |
71
+
72
+ ## Observability
73
+
74
+ | variable | default | what it does |
75
+ |---|---|---|
76
+ | `AKAN_MEMORY_LOG` | off | `=1` logs a periodic memory report from each server role. |
77
+ | `AKAN_MEMORY_LOG_INTERVAL_MS` | `60000` | How often that report is written. |
78
+ | `AKAN_MEMORY_GC_ON_REPORT` | off | `=1` forces a GC before each report, so the number is retained memory rather than garbage. Costs a full GC per report. |
79
+
80
+ ## Sizing a small sandbox
81
+
82
+ A worked example, for a 1.2GB container:
83
+
84
+ ```bash
85
+ AKAN_MEMORY_LIMIT=1200mb # builder gets ~420MB, rsc worker ~660MB
86
+ AKAN_DEV_IDLE_SUSPEND_MS=300000 # release the builder after 5 idle minutes
87
+ ```
88
+
89
+ Two things to expect at that size. The builder crosses 420MB during ordinary work — one route build
90
+ costs ~247MB on top of its floor — so it is replaced roughly once per 30s while you keep building, each
91
+ replacement costing a boot build that requests wait through rather than fail. And the build worker's
92
+ peak is not covered by any of these ceilings; if the kernel OOM-kills it, the dev server survives with a
93
+ red build for that generation and the log names the signal.
94
+
95
+ Raising `AKAN_BUILDER_MAX_RSS_MB` above the derived share trades memory for fewer boot builds. Setting
96
+ it to `0` trades the bound away entirely, which on a container this size means the kernel decides
97
+ instead.
package/README.md CHANGED
@@ -45,6 +45,12 @@ await runner.build();
45
45
  - AI prompt, guideline, and code-generation support utilities.
46
46
  - Capacitor and mobile release helpers.
47
47
 
48
+ ## Dev Server Sizing
49
+
50
+ The dev server bounds its own memory by recycling the processes that grow, and every threshold it uses
51
+ can be set from the environment. [`DEV_RUNTIME_KNOBS.md`](./DEV_RUNTIME_KNOBS.md) lists them with their
52
+ defaults, the shares they derive from `AKAN_MEMORY_LIMIT`, and what a small container should expect.
53
+
48
54
  ## Package Boundary
49
55
 
50
56
  - Runtime code should import from `akanjs`, including shared config types such as `AppConfig`, `LibConfig`,
@@ -14,15 +14,17 @@ import {
14
14
  decideBuilderRssRecycle,
15
15
  decideBuilderRssSettle,
16
16
  decideIdleSuspend,
17
+ filesChangedSince,
17
18
  hasAnyBuildFailure,
18
19
  hasBuildFailureForGeneration,
19
20
  isLegacyBackendFallbackFile,
21
+ isRssCeilingUnreachable,
20
22
  mergeBackendRestartReasons,
21
23
  mergeInvalidateMessages,
22
24
  normalizeBackendReportedGeneration,
23
25
  resolveIdleSuspendMs,
24
26
  shouldAbandonBackendRecovery,
25
- shouldAbandonBuilderRssCeiling,
27
+ shouldHoldForReturningBuilder,
26
28
  shouldMarkBuildPhaseRecovered,
27
29
  shouldQueueBuildStatusReplay,
28
30
  shouldRefreshConfigOnIdleWake,
@@ -31,6 +33,7 @@ import {
31
33
  shouldRestartBackendByDevPlan,
32
34
  shouldRestartBuilderByDevPlan,
33
35
  shouldRestartDevHostByDevPlan,
36
+ shouldWarnBuilderRssCeilingTight,
34
37
  } from "./akanApp.host";
35
38
 
36
39
  const invalidateWithActions = (actions: DevChangeAction[]): Extract<BuilderMessage, { type: "invalidate" }> => ({
@@ -156,6 +159,28 @@ describe("last-good frontend helpers", () => {
156
159
  });
157
160
  });
158
161
 
162
+ describe("holding requests for a returning builder", () => {
163
+ test("holds while the builder is on its way back", () => {
164
+ expect(shouldHoldForReturningBuilder({ status: "restarting", heldCount: 0 })).toBe(true);
165
+ expect(shouldHoldForReturningBuilder({ status: "starting", heldCount: 3 })).toBe(true);
166
+ });
167
+
168
+ test("holds through the drain too, not only after the process is gone", () => {
169
+ // The window this decision originally missed: the builder is still alive and refusing, which is
170
+ // the same gap as a restart from anyone waiting on a page.
171
+ expect(shouldHoldForReturningBuilder({ status: "recycling", heldCount: 0 })).toBe(true);
172
+ });
173
+
174
+ test("fails immediately when nothing is bringing the builder back", () => {
175
+ expect(shouldHoldForReturningBuilder({ status: "stopped", heldCount: 0 })).toBe(false);
176
+ });
177
+
178
+ test("stops holding once the queue is full", () => {
179
+ expect(shouldHoldForReturningBuilder({ status: "restarting", heldCount: 3, limit: 4 })).toBe(true);
180
+ expect(shouldHoldForReturningBuilder({ status: "restarting", heldCount: 4, limit: 4 })).toBe(false);
181
+ });
182
+ });
183
+
159
184
  describe("builder rss recycle", () => {
160
185
  const ceiling = 1_200 * 1024 * 1024;
161
186
  const decide = (over: Partial<Parameters<typeof decideBuilderRssRecycle>[0]>) =>
@@ -191,11 +216,23 @@ describe("builder rss recycle", () => {
191
216
  expect(decide({ msSinceLastRecycle: 5_000, minIntervalMs: 1_000 })).toBe("recycle");
192
217
  });
193
218
 
194
- // An app whose fresh boot is already over the ceiling would otherwise be recycled forever.
195
- test("abandons the ceiling once recycling stops buying relief", () => {
196
- expect(shouldAbandonBuilderRssCeiling(1)).toBe(false);
197
- expect(shouldAbandonBuilderRssCeiling(2)).toBe(true);
198
- expect(shouldAbandonBuilderRssCeiling(1, 1)).toBe(true);
219
+ // Says so, and keeps enforcing: a page load is two route builds, so an app whose builds sit over the
220
+ // ceiling reaches this on its first navigation — which is normal work, not a reason to drop the only
221
+ // bound the builder has.
222
+ test("mentions a tight ceiling rather than acting on it", () => {
223
+ expect(shouldWarnBuilderRssCeilingTight(1)).toBe(false);
224
+ expect(shouldWarnBuilderRssCeilingTight(2)).toBe(true);
225
+ expect(shouldWarnBuilderRssCeilingTight(1, 1)).toBe(true);
226
+ });
227
+
228
+ // The one case recycling cannot fix, measured on the replacement before it has built anything: every
229
+ // future replacement lands on the same floor, so the loop would only ever cost boot builds.
230
+ test("gives up only when a fresh builder is already over the ceiling", () => {
231
+ expect(isRssCeilingUnreachable(ceiling + 1, ceiling)).toBe(true);
232
+ expect(isRssCeilingUnreachable(ceiling - 1, ceiling)).toBe(false);
233
+ // An unreadable rss is no information, and no ceiling is nothing to be unreachable.
234
+ expect(isRssCeilingUnreachable(null, ceiling)).toBe(false);
235
+ expect(isRssCeilingUnreachable(ceiling + 1, null)).toBe(false);
199
236
  });
200
237
 
201
238
  // Measured on Linux: the builder peaked at 522MiB and settled at 214MiB with no help, so a 400MiB
@@ -465,6 +502,38 @@ describe("BackendImportGraph", () => {
465
502
  expect(graph.has(path.join(cwdPath, "lib/leaving.ts"))).toBe(false);
466
503
  });
467
504
 
505
+ test("reports which backend files moved while nobody was watching", async () => {
506
+ const { graph, cwdPath } = await makeGraph({
507
+ "main.ts": 'import "./server";\n',
508
+ "server.ts": 'import "./lib/handler";\nexport default 1;\n',
509
+ "lib/handler.ts": "export const handler = () => null;\n",
510
+ });
511
+ await graph.refresh();
512
+ const before = await graph.fingerprint();
513
+
514
+ // The builder is gone here, so no watcher event exists for this save — which is the whole reason
515
+ // the stamps are taken. `mtimeMs` has a coarse clock on Linux, so the size has to move too.
516
+ await writeFile(path.join(cwdPath, "lib/handler.ts"), "export const handler = () => 'changed';\n");
517
+
518
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([path.join(cwdPath, "lib/handler.ts")]);
519
+ });
520
+
521
+ test("says nothing when the tree is untouched, and names a deleted file", async () => {
522
+ const { graph, cwdPath } = await makeGraph({
523
+ "main.ts": 'import "./server";\n',
524
+ "server.ts": 'import "./lib/handler";\nexport default 1;\n',
525
+ "lib/handler.ts": "export const handler = () => null;\n",
526
+ });
527
+ await graph.refresh();
528
+ const before = await graph.fingerprint();
529
+ // A recycle with no edit in it is the common case, and it must not cost a backend restart.
530
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([]);
531
+
532
+ await rm(path.join(cwdPath, "lib/handler.ts"));
533
+ // Deleted counts as changed: the backend is still running what used to be there.
534
+ expect(filesChangedSince(before, await graph.fingerprint())).toEqual([path.join(cwdPath, "lib/handler.ts")]);
535
+ });
536
+
468
537
  test("keeps the previous graph when a refresh finds no entrypoints", async () => {
469
538
  const { graph, cwdPath } = await makeGraph({
470
539
  "main.ts": 'import "./lib/kept";\n',