@akanjs/devkit 2.4.1-rc.7 → 2.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/DEV_RUNTIME_KNOBS.md +97 -0
- package/README.md +6 -0
- package/akanApp/akanApp.host.test.ts +75 -6
- package/akanApp/akanApp.host.ts +303 -25
- package/commandDecorators/command.ts +16 -1
- package/frontendBuild/clientEntryDiscovery.ts +91 -53
- package/frontendBuild/cssCandidateCache.ts +109 -0
- package/frontendBuild/cssCompiler.ts +70 -18
- package/frontendBuild/frontendBuild.test.ts +3 -0
- package/frontendBuild/hmrWatcher.ts +6 -0
- package/frontendBuild/sourceMtimeIndex.test.ts +51 -2
- package/frontendBuild/sourceMtimeIndex.ts +66 -5
- package/incrementalBuilder/buildBatchRunner.ts +10 -1
- package/incrementalBuilder/builderChannel.test.ts +16 -7
- package/incrementalBuilder/builderRequestRouter.test.ts +89 -0
- package/incrementalBuilder/builderRequestRouter.ts +66 -0
- package/incrementalBuilder/incrementalBuilder.host.test.ts +28 -1
- package/incrementalBuilder/incrementalBuilder.host.ts +24 -2
- package/incrementalBuilder/incrementalBuilder.proc.ts +14 -13
- package/integration/devResourceProbe.ts +319 -0
- package/integration/devStability.integration.test.ts +153 -4
- package/integration/devStabilityHarness.ts +30 -5
- package/package.json +2 -2
- 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
|
-
|
|
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
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
expect(
|
|
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',
|