akanjs 2.4.1-rc.2 → 2.4.1-rc.4

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 (50) hide show
  1. package/client/device.ts +1 -1
  2. package/client/router.ts +12 -3
  3. package/dictionary/dictionaryRegistry.ts +87 -0
  4. package/dictionary/index.ts +1 -0
  5. package/dictionary/trans.ts +2 -0
  6. package/package.json +11 -1
  7. package/server/SSR_MEMORY_DIAGNOSIS.md +47 -0
  8. package/server/akanApp.ts +2 -2
  9. package/server/akanServer.ts +27 -13
  10. package/server/artifact/builderRpc.ts +51 -7
  11. package/server/artifact/ipcTypes.ts +40 -2
  12. package/server/devtools/constantSerializer.ts +195 -0
  13. package/server/devtools/depsSerializer.ts +250 -0
  14. package/server/devtools/devtoolsJson.ts +81 -0
  15. package/server/devtools/devtoolsRouter.ts +148 -0
  16. package/server/devtools/index.ts +6 -0
  17. package/server/devtools/signalSerializer.ts +338 -0
  18. package/server/devtools/types.ts +286 -0
  19. package/server/di/diLifecycle.ts +20 -0
  20. package/server/index.ts +1 -0
  21. package/server/memoryLimit.ts +77 -0
  22. package/server/resolver/signal.resolver.ts +2 -2
  23. package/server/rscWorkerHost.ts +12 -48
  24. package/types/dictionary/base.dictionary.d.ts +1 -1
  25. package/types/dictionary/dictionary.d.ts +8 -8
  26. package/types/dictionary/dictionaryRegistry.d.ts +26 -0
  27. package/types/dictionary/index.d.ts +1 -0
  28. package/types/server/artifact/ipcTypes.d.ts +41 -1
  29. package/types/server/devtools/constantSerializer.d.ts +11 -0
  30. package/types/server/devtools/depsSerializer.d.ts +25 -0
  31. package/types/server/devtools/devtoolsJson.d.ts +13 -0
  32. package/types/server/devtools/devtoolsRouter.d.ts +33 -0
  33. package/types/server/devtools/index.d.ts +6 -0
  34. package/types/server/devtools/signalSerializer.d.ts +21 -0
  35. package/types/server/devtools/types.d.ts +292 -0
  36. package/types/server/devtools.d.ts +1 -0
  37. package/types/server/di/diLifecycle.d.ts +12 -1
  38. package/types/server/index.d.ts +1 -0
  39. package/types/server/memoryLimit.d.ts +26 -0
  40. package/types/server/resolver/signal.resolver.d.ts +6 -0
  41. package/types/ui/System/frameCssVars.d.ts +1 -1
  42. package/types/webkit/useFrameRuntime.d.ts +1 -1
  43. package/ui/Layout/TopInset.tsx +10 -13
  44. package/ui/ServerPortal.tsx +1 -1
  45. package/ui/System/CSR.tsx +30 -34
  46. package/ui/System/Client.tsx +4 -5
  47. package/ui/System/SSR.tsx +70 -70
  48. package/ui/System/frameCssVars.ts +1 -1
  49. package/webkit/useCsrValues.ts +58 -27
  50. package/webkit/useFrameRuntime.ts +37 -34
package/client/device.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
  import type { RefObject } from "react";
3
- import { debugFrame } from "./frameDebug";
4
3
  import type {
5
4
  CapacitorDeviceInfo,
6
5
  CapacitorHapticsModule,
7
6
  CapacitorKeyboardInfo,
8
7
  CapacitorKeyboardModule,
9
8
  } from "./capacitor";
9
+ import { debugFrame } from "./frameDebug";
10
10
 
11
11
  type DeviceInfo = CapacitorDeviceInfo;
12
12
  type Keyboard = CapacitorKeyboardModule["Keyboard"];
package/client/router.ts CHANGED
@@ -112,7 +112,10 @@ const normalizeRouteManifestPath = (href: string, prefix = "") => {
112
112
  return segments.length === 0 ? "/" : `/${segments.join("/")}`;
113
113
  };
114
114
 
115
- export const normalizeDeepLinkHref = (href: string, origin = globalThis.window?.location?.origin ?? "http://localhost") => {
115
+ export const normalizeDeepLinkHref = (
116
+ href: string,
117
+ origin = globalThis.window?.location?.origin ?? "http://localhost",
118
+ ) => {
116
119
  const url = new URL(href, origin);
117
120
  if (url.protocol === "http:" || url.protocol === "https:") return `${url.pathname}${url.search}${url.hash}`;
118
121
  const hostPath = url.hostname ? `/${url.hostname}` : "";
@@ -268,7 +271,11 @@ class Router {
268
271
  const state = (history.state ?? {}) as Record<string, unknown>;
269
272
  const akanState = (state.__akanRouter ?? {}) as { idx?: number };
270
273
  this.#historyIdx = Number.isInteger(akanState.idx) ? (akanState.idx as number) : this.#historyIdx;
271
- history.replaceState({ ...state, __akanRouter: { ...akanState, idx: this.#historyIdx } }, "", globalThis.window.location.href);
274
+ history.replaceState(
275
+ { ...state, __akanRouter: { ...akanState, idx: this.#historyIdx } },
276
+ "",
277
+ globalThis.window.location.href,
278
+ );
272
279
  if (!globalThis.window.addEventListener) return;
273
280
  globalThis.window.addEventListener("popstate", (event) => {
274
281
  const nextState = ((event.state as Record<string, unknown> | null)?.__akanRouter ?? {}) as { idx?: number };
@@ -336,7 +343,9 @@ class Router {
336
343
  }
337
344
  const stack = this.#routePaths.size > 0 ? this.#getExistingSegmentStack(path) : [path];
338
345
  const baseStack =
339
- stack.length > 1 || this.#indexPath === path || stack.includes(this.#indexPath) ? stack : [this.#indexPath, ...stack];
346
+ stack.length > 1 || this.#indexPath === path || stack.includes(this.#indexPath)
347
+ ? stack
348
+ : [this.#indexPath, ...stack];
340
349
  const dedupedStack = baseStack.filter((candidate, index) => baseStack.indexOf(candidate) === index);
341
350
  const target = `${path}${search ? `?${search}` : ""}${hash ? `#${hash}` : ""}`;
342
351
  return dedupedStack.map((candidate, index) => (index === dedupedStack.length - 1 ? target : candidate));
@@ -0,0 +1,87 @@
1
+ import { ModelDictInfo, ScalarDictInfo, ServiceDictInfo } from "./dictInfo";
2
+ import type { DictModule } from "./locale";
3
+ import type { DictionaryNode, RootDictionary } from "./trans";
4
+
5
+ export type DictionaryModuleKind = "model" | "scalar" | "service";
6
+
7
+ export interface DictionaryModuleInfo {
8
+ kind: DictionaryModuleKind;
9
+ languages: string[];
10
+ }
11
+
12
+ /**
13
+ * Collects every dictionary tree built by `makeTrans` so a server process can read the merged result.
14
+ *
15
+ * `makeTrans` keeps its `rootDictionary` in a closure and `AkanLib` carries no dictionary, so without this
16
+ * the i18n tree is unreachable from `AkanServer`. Registration happens at module-evaluation time, which the
17
+ * API process reaches because it imports the generated `server.ts` (which re-exports `lib/dict.ts`) whole.
18
+ */
19
+ export class DictionaryRegistry {
20
+ static readonly #roots: RootDictionary[] = [];
21
+ static readonly #modules = new Map<string, DictionaryModuleInfo>();
22
+
23
+ /** Registration order is base → libs → app, matching `makeDictionary`, so a later root wins on conflict. */
24
+ static register(root: RootDictionary, transMap: Record<string, DictModule<string, string>>) {
25
+ DictionaryRegistry.#roots.push(root);
26
+ Object.entries(transMap).forEach(([refName, trans]) => {
27
+ DictionaryRegistry.#modules.set(refName, {
28
+ kind: DictionaryRegistry.#resolveKind(trans),
29
+ languages: [...((trans.dict as { languages?: string[] }).languages ?? [])],
30
+ });
31
+ });
32
+ }
33
+
34
+ static getRoot(): RootDictionary {
35
+ const merged = {} as RootDictionary;
36
+ DictionaryRegistry.#roots.forEach((root) => {
37
+ Object.entries(root).forEach(([language, models]) => {
38
+ merged[language] = { ...(merged[language] ?? {}), ...models };
39
+ });
40
+ });
41
+ return merged;
42
+ }
43
+
44
+ static getLanguages(): string[] {
45
+ return [...new Set(DictionaryRegistry.#roots.flatMap((root) => Object.keys(root)))];
46
+ }
47
+
48
+ static getModules(): Record<string, DictionaryModuleInfo> {
49
+ return Object.fromEntries([...DictionaryRegistry.#modules.entries()].map(([key, info]) => [key, { ...info }]));
50
+ }
51
+
52
+ /** Flattened dotted paths of every leaf translation, e.g. `"user.signal.createUser.arg.data"`. */
53
+ static getKeys(root: RootDictionary = DictionaryRegistry.getRoot()): string[] {
54
+ const keys = new Set<string>();
55
+ Object.values(root).forEach((models) => {
56
+ Object.entries(models).forEach(([refName, node]) => {
57
+ DictionaryRegistry.#collectKeys(refName, node, keys);
58
+ });
59
+ });
60
+ return [...keys].sort();
61
+ }
62
+
63
+ /** Test-only reset; the registry is module-level global state. */
64
+ static clear() {
65
+ DictionaryRegistry.#roots.length = 0;
66
+ DictionaryRegistry.#modules.clear();
67
+ }
68
+
69
+ static #collectKeys(path: string, node: DictionaryNode, keys: Set<string>) {
70
+ if (typeof node.t === "string") keys.add(path);
71
+ Object.entries(node).forEach(([key, value]) => {
72
+ if (key === "t" || !DictionaryRegistry.#isNode(value)) return;
73
+ DictionaryRegistry.#collectKeys(`${path}.${key}`, value, keys);
74
+ });
75
+ }
76
+
77
+ static #isNode(value: unknown): value is DictionaryNode {
78
+ return typeof value === "object" && value !== null && !Array.isArray(value);
79
+ }
80
+
81
+ static #resolveKind(trans: DictModule<string, string>): DictionaryModuleKind {
82
+ if (trans.dict instanceof ModelDictInfo) return "model";
83
+ if (trans.dict instanceof ScalarDictInfo) return "scalar";
84
+ if (trans.dict instanceof ServiceDictInfo) return "service";
85
+ return "service";
86
+ }
87
+ }
@@ -8,5 +8,6 @@ export {
8
8
  getDictionary,
9
9
  translate,
10
10
  } from "./dictionary";
11
+ export * from "./dictionaryRegistry";
11
12
  export * from "./locale";
12
13
  export * from "./trans";
@@ -1,6 +1,7 @@
1
1
  import type { GetStateObject, ObjectAssign, Prettify } from "akanjs/base";
2
2
  import { pathGet } from "akanjs/common";
3
3
 
4
+ import { DictionaryRegistry } from "./dictionaryRegistry";
4
5
  import type { DictModule } from "./locale";
5
6
 
6
7
  type TranslationSingle = readonly [string, string] | readonly [string, string, string, string];
@@ -106,6 +107,7 @@ export const makeTrans = <
106
107
  Object.entries(transMap).forEach(([refName, trans]) => {
107
108
  trans.dict._registerToRoot(refName, rootDictionary);
108
109
  });
110
+ DictionaryRegistry.register(rootDictionary, transMap);
109
111
  class Err extends Error {
110
112
  readonly error: string;
111
113
  readonly statusCode: number;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akanjs",
3
- "version": "2.4.1-rc.2",
3
+ "version": "2.4.1-rc.4",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -165,6 +165,16 @@
165
165
  "types": "./types/server/rscWorker.d.ts",
166
166
  "import": "./server/rscWorker.tsx",
167
167
  "default": "./server/rscWorker.tsx"
168
+ },
169
+ "./server/memoryLimit": {
170
+ "types": "./types/server/memoryLimit.d.ts",
171
+ "import": "./server/memoryLimit.ts",
172
+ "default": "./server/memoryLimit.ts"
173
+ },
174
+ "./server/lifecycle/portInUse": {
175
+ "types": "./types/server/lifecycle/portInUse.d.ts",
176
+ "import": "./server/lifecycle/portInUse.ts",
177
+ "default": "./server/lifecycle/portInUse.ts"
168
178
  }
169
179
  },
170
180
  "dependencies": {
@@ -105,3 +105,50 @@ AKAN_RSC_WORKER_RECYCLE_GRACE_MS=5000
105
105
  ```
106
106
 
107
107
  Validate that recycle happens only when `rscPendingRenderCount` is `0`, and that the new `rscWorkerPid` becomes ready before traffic continues.
108
+
109
+ ## Dev Builder Recycle
110
+
111
+ The dev builder has the same problem for a different reason: `Bun.build` retains native bundler arenas
112
+ that no GC reclaims, so the process only returns that memory by exiting. `AkanAppHost` therefore
113
+ recycles it — gracefully, after its queues drain and once it has stayed quiet — past a ceiling:
114
+
115
+ ```sh
116
+ AKAN_BUILDER_MAX_RSS_MB=1200 # 0 leaves the builder unbounded; default is 1200 in dev
117
+ AKAN_BUILDER_MAX_RSS=1200mb # same ceiling with a unit suffix
118
+ ```
119
+
120
+ Both resolve through `MemoryLimit.resolveMaxRssBytes`, so `AKAN_MEMORY_LIMIT` or a cgroup limit also
121
+ applies (the builder takes 35% of it, the RSC worker 55%). Look for `recycling builder pid=…` followed
122
+ by `exiting for recycle` and `announced boot state after recycle`; the last line is what re-points a
123
+ running backend at the replacement's artifact.
124
+
125
+ **How much of that RSS is actually unreclaimable is platform-specific.** Measured on Bun 1.3.14: macOS
126
+ returns none of the bundler arenas (0% after 60s idle) while Linux purges them after ~10-15s of quiet,
127
+ 46-59% of the builder's peak. Since the builder reports its RSS only when its queues drain, that sample
128
+ is its *peak* — so before committing, the host waits 20s and re-reads the builder's RSS from the OS,
129
+ and skips the recycle if the allocator gave the memory back by itself:
130
+
131
+ ```
132
+ [builder-recycle] holding 20s to see whether the allocator returns it (rss=522MiB>=400MiB after 6 build(s))
133
+ [builder-recycle] skipped: the builder fell to 214MiB (ceiling 400MiB) on its own, …
134
+ ```
135
+
136
+ A builder more than 1.5× over the ceiling skips the wait, since no purge would rescue it. On macOS the
137
+ re-read returns the same value, so the only cost there is the delay.
138
+
139
+ ## Dev Idle Suspend
140
+
141
+ A dev server that nobody is editing still pays for a builder. After five minutes with no build
142
+ activity and no route request, `AkanAppHost` releases the builder entirely and watches for edits with
143
+ a plain `fs.watch` instead. The backend stays up, so the preview URL keeps serving; only build
144
+ capacity goes away. The next edit — or the first request that needs a build — brings it back, paying
145
+ one cold boot build (~3.5s on `apps/akan`, ~0.3s on a single-route app).
146
+
147
+ ```sh
148
+ AKAN_DEV_IDLE_SUSPEND_MS=300000 # default; 0 keeps the builder resident for the whole session
149
+ ```
150
+
151
+ Requests that arrive mid-wake are held and replayed against the new builder rather than answered with
152
+ `builder is stopped`. A suspend is skipped while a build is red, while any restart or recovery is
153
+ pending, or within 30s of a previous wake. Look for `[idle-suspend] … released the builder`, then
154
+ `[idle-suspend] waking (…)` and `[idle-suspend] awake in …ms`.
package/server/akanApp.ts CHANGED
@@ -1197,7 +1197,7 @@ export class AkanApp {
1197
1197
  const reader = stream.getReader();
1198
1198
  const decoder = new TextDecoder();
1199
1199
  try {
1200
- while (true) {
1200
+ for (;;) {
1201
1201
  const { done, value } = await reader.read();
1202
1202
  if (done) break;
1203
1203
  const text = decoder.decode(value, { stream: true });
@@ -1213,7 +1213,7 @@ export class AkanApp {
1213
1213
 
1214
1214
  #writeChildOutput(idx: number, role: AkanChildRole, type: "stdout" | "stderr", bufferKey: string, text: string) {
1215
1215
  let buffered = `${this.#childOutputBuffers.get(bufferKey) ?? ""}${text}`;
1216
- while (true) {
1216
+ for (;;) {
1217
1217
  const newlineIdx = buffered.indexOf("\n");
1218
1218
  if (newlineIdx === -1) break;
1219
1219
  const line = buffered.slice(0, newlineIdx + 1);
@@ -14,6 +14,7 @@ import { createOpenApiDocument } from "../signal/openapi";
14
14
  import { FetchSerializer } from "../signal/serializer";
15
15
  import type { AkanLib, AkanLibProps } from "./akanLib";
16
16
  import type { BuilderRpc } from "./artifact";
17
+ import { DevtoolsRouter } from "./devtools";
17
18
  import { DiLifecycle } from "./di/diLifecycle";
18
19
  import type { HmrWsData, HmrWsHub } from "./hmr/wsHub";
19
20
  import { isPortInUseError } from "./lifecycle/portInUse";
@@ -449,19 +450,32 @@ export class AkanServer {
449
450
  }
450
451
 
451
452
  #createBuiltinRoutes(): HttpRoutes {
452
- if (!this.openapi) return {};
453
- return {
454
- "/openapi.json": {
455
- GET: () =>
456
- Response.json(
457
- createOpenApiDocument(FetchSerializer.serializeRegistry(this.#di.live).signal, {
458
- title: `${this.env.appName} API`,
459
- version: "0.0.0",
460
- servers: this.#getOpenApiServers(),
461
- }),
462
- ),
463
- },
464
- };
453
+ const openapiRoutes: HttpRoutes = this.openapi
454
+ ? {
455
+ "/openapi.json": {
456
+ GET: () =>
457
+ Response.json(
458
+ createOpenApiDocument(FetchSerializer.serializeRegistry(this.#di.live).signal, {
459
+ title: `${this.env.appName} API`,
460
+ version: "0.0.0",
461
+ servers: this.#getOpenApiServers(),
462
+ }),
463
+ ),
464
+ },
465
+ }
466
+ : {};
467
+
468
+ const devtoolsRoutes = new DevtoolsRouter({
469
+ di: this.#di,
470
+ env: this.env,
471
+ name: this.name,
472
+ serverMode: this.serverMode,
473
+ prefix: this.prefix,
474
+ websocketPrefix: this.websocketPrefix,
475
+ openapi: this.openapi,
476
+ getStatus: () => this.status,
477
+ }).createRoutes();
478
+ return { ...openapiRoutes, ...devtoolsRoutes };
465
479
  }
466
480
 
467
481
  #getOpenApiServers() {
@@ -96,10 +96,9 @@ export class BuilderRpc {
96
96
  ): Promise<BuildRouteClientResult> {
97
97
  if (this.#disposed) throw new Error("[builder] rpc is disposed");
98
98
  const id = this.#nextId++;
99
- const payload = await new Promise<BuildRouteResultPayload>((resolve, reject) => {
100
- this.#pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
101
- this.#send({ type: "build-route", id, routeId, seeds, knownEntries: [...knownEntries], generation });
102
- });
99
+ const payload = await this.#request<BuildRouteResultPayload>(id, `build-route ${routeId}`, () =>
100
+ this.#send({ type: "build-route", id, routeId, seeds, knownEntries: [...knownEntries], generation }),
101
+ );
103
102
  return {
104
103
  manifestDelta: payload.manifestDelta,
105
104
  ssrManifestDelta: { moduleLoading: null, moduleMap: payload.ssrManifestDelta },
@@ -118,9 +117,54 @@ export class BuilderRpc {
118
117
  async buildCsr(reason: string): Promise<void> {
119
118
  if (this.#disposed) throw new Error("[builder] rpc is disposed");
120
119
  const id = this.#nextId++;
121
- await new Promise<void>((resolve, reject) => {
122
- this.#pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
123
- this.#send({ type: "build-csr", id, reason });
120
+ await this.#request<void>(id, `build-csr (${reason})`, () => this.#send({ type: "build-csr", id, reason }));
121
+ }
122
+
123
+ /**
124
+ * How long to wait for the builder before failing a request. `AKAN_BUILDER_RPC_TIMEOUT_MS` overrides.
125
+ *
126
+ * Generous, because a cold CSR build of every page legitimately takes tens of seconds; the point is not
127
+ * to be tight but to be finite.
128
+ */
129
+ static #timeoutMs(): number {
130
+ const configured = Number(process.env.AKAN_BUILDER_RPC_TIMEOUT_MS);
131
+ if (Number.isFinite(configured) && configured > 0) return configured;
132
+ return 120_000;
133
+ }
134
+
135
+ /**
136
+ * One builder request, which fails rather than waiting forever.
137
+ *
138
+ * Nothing answers a request whose builder exits after receiving it: the dev host replies `ok:false` only
139
+ * when the *send* fails, and it does not track in-flight ids. The builder exits routinely — it is recycled
140
+ * every few builds once its RSS passes the ceiling — so a page request that happens to be mid route-build
141
+ * left this promise pending forever, and the browser tab span with no error, no log and nothing to retry.
142
+ * Observed from the other side as a `fetch` that sat for 225s against a 60s budget.
143
+ */
144
+ async #request<T>(id: number, label: string, send: () => void): Promise<T> {
145
+ const timeoutMs = BuilderRpc.#timeoutMs();
146
+ return await new Promise<T>((resolve, reject) => {
147
+ const timer = setTimeout(() => {
148
+ this.#pending.delete(id);
149
+ reject(
150
+ new Error(
151
+ `[builder] ${label} got no answer in ${timeoutMs}ms; the builder was likely recycled or restarted mid-request — reload to retry`,
152
+ ),
153
+ );
154
+ }, timeoutMs);
155
+
156
+ timer.unref?.();
157
+ this.#pending.set(id, {
158
+ resolve: (value) => {
159
+ clearTimeout(timer);
160
+ resolve(value as T);
161
+ },
162
+ reject: (error) => {
163
+ clearTimeout(timer);
164
+ reject(error);
165
+ },
166
+ });
167
+ send();
124
168
  });
125
169
  }
126
170
 
@@ -9,11 +9,21 @@ export interface BuildRouteResultPayload {
9
9
  generation?: number;
10
10
  }
11
11
 
12
+ /**
13
+ * Marks a frontend payload that re-announces a freshly booted artifact rather than reporting an edit.
14
+ * A recycled builder rebuilds every artifact from scratch, but a running backend read
15
+ * `base-artifact.json` once at boot and never re-reads it, so the new state has to be pushed. The
16
+ * host drops such a payload when the hashed output did not actually move, which keeps a clean
17
+ * recycle invisible to connected browsers.
18
+ */
19
+ export type BuilderStateReason = "builder-recycle";
20
+
12
21
  export interface CssPayload {
13
22
  cssAssets: Record<string, { cssUrl: string; cssRelPath: string }>;
14
23
  cssBase64ByUrl: Record<string, string>;
15
24
  generation?: number;
16
25
  changedFiles?: string[];
26
+ reason?: BuilderStateReason;
17
27
  }
18
28
 
19
29
  export type DevChangeRole = "server" | "client" | "shared" | "barrel" | "config" | "css";
@@ -71,11 +81,38 @@ export type BuilderCsrRes =
71
81
  | { type: "build-csr-res"; id: number; ok: true }
72
82
  | { type: "build-csr-res"; id: number; ok: false; error: string };
73
83
 
84
+ /**
85
+ * Asks the builder to finish its queued work and exit, which is the only way bundler memory is
86
+ * returned to the OS (see `BuilderMetrics`). The host's existing restart path brings up a
87
+ * replacement, so a graceful drain — rather than a kill — keeps a rebuild in flight from being
88
+ * truncated.
89
+ */
90
+ export type BuilderControl = { type: "builder-shutdown"; reason: string };
91
+
74
92
  export interface PagesBundlePayload {
75
93
  bundlePath: string;
76
94
  buildId: number;
77
95
  generation?: number;
78
96
  changedFiles?: string[];
97
+ reason?: BuilderStateReason;
98
+ }
99
+
100
+ /**
101
+ * `Bun.build` retains native bundler arenas that `Bun.gc(true)` never reclaims — the JS heap stays flat
102
+ * while RSS climbs. **How much comes back without exiting is platform-specific**: measured on Bun
103
+ * 1.3.14, macOS returns none of it (0% after 60s idle) while Linux purges 46-59% after ~10-15s idle.
104
+ * The dev host recycles past a ceiling to bound the macOS case.
105
+ *
106
+ * Because this is only reported when the builder's queues drain, `rssBytes` is a *peak* sample. On
107
+ * Linux it goes stale within seconds, so the host re-reads the builder's RSS from the OS before acting
108
+ * on it. See `local/optimize-resource/09-linux-retention-measurement.md`.
109
+ */
110
+ export interface BuilderMetrics {
111
+ rssBytes: number;
112
+ /** The builder's newest generation; 0 until it has processed a watch batch since spawning. */
113
+ generation: number;
114
+ /** Work items completed since this builder spawned, so a host can require real work before recycling. */
115
+ workCount: number;
79
116
  }
80
117
 
81
118
  export type BuilderEvent =
@@ -90,6 +127,7 @@ export type BuilderEvent =
90
127
  }
91
128
  | { type: "css-updated"; data: CssPayload }
92
129
  | { type: "pages-updated"; data: PagesBundlePayload }
93
- | { type: "build-status"; data: DevBuildStatus };
130
+ | { type: "build-status"; data: DevBuildStatus }
131
+ | { type: "builder-metrics"; data: BuilderMetrics };
94
132
 
95
- export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderEvent;
133
+ export type BuilderMessage = BuilderReq | BuilderRes | BuilderCsrReq | BuilderCsrRes | BuilderControl | BuilderEvent;
@@ -0,0 +1,195 @@
1
+ import {
2
+ type Cls,
3
+ type EnumInstance,
4
+ FIELD_META,
5
+ getNonArrayModel,
6
+ isEnum,
7
+ PrimitiveRegistry,
8
+ type PrimitiveScalar,
9
+ } from "akanjs/base";
10
+ import { type ConstantCls, type ConstantField, ConstantRegistry, type ConstantType } from "akanjs/constant";
11
+ import { DatabaseRegistry, type FilterInfo, getFilterMeta } from "akanjs/document";
12
+ import { DevtoolsJson } from "./devtoolsJson";
13
+ import type {
14
+ ConstantData,
15
+ ConstantFieldNode,
16
+ ConstantModelNode,
17
+ ConstantModelView,
18
+ ConstantModelViewKey,
19
+ EnumNode,
20
+ FieldType,
21
+ FilterArg,
22
+ RelationEdge,
23
+ } from "./types";
24
+
25
+ const modelViewKeys = ["input", "object", "full", "light", "insight"] as const;
26
+
27
+ /**
28
+ * Walks `ConstantRegistry` into the `/_akan/constant` payload.
29
+ *
30
+ * Model references are emitted by name only, never inlined, so a cyclic schema still produces a finite
31
+ * document. Anything the registry holds that JSON cannot express goes through {@link DevtoolsJson}.
32
+ */
33
+ export class ConstantSerializer {
34
+ static serialize(): ConstantData {
35
+ const models: Record<string, ConstantModelNode> = {};
36
+ const relations: RelationEdge[] = [];
37
+ for (const [refName, cnst] of ConstantRegistry.database.entries()) {
38
+ const views = {} as Record<ConstantModelViewKey, ConstantModelView>;
39
+ const modelNames = {} as Record<ConstantModelViewKey, string>;
40
+ for (const viewKey of modelViewKeys) {
41
+ const view = ConstantSerializer.#serializeView(cnst[viewKey] as ConstantCls, viewKey);
42
+ views[viewKey] = view;
43
+ modelNames[viewKey] = view.modelName;
44
+ relations.push(...ConstantSerializer.#collectRelations(refName, view));
45
+ }
46
+ models[refName] = {
47
+ refName,
48
+ modelNames,
49
+ views,
50
+ ...ConstantSerializer.#serializeFilter(refName),
51
+ };
52
+ }
53
+
54
+ const scalars: Record<string, ConstantModelView> = {};
55
+ for (const [refName, scalar] of ConstantRegistry.scalar.entries()) {
56
+ scalars[refName] = ConstantSerializer.#serializeView(scalar.model, "scalar");
57
+ }
58
+
59
+ const enums: Record<string, EnumNode> = {};
60
+ for (const [key, enumRef] of ConstantRegistry.enum.entries()) {
61
+ enums[key] = { key, refName: enumRef.refName, values: [...enumRef.values] as (string | number)[] };
62
+ }
63
+
64
+ const values: Record<string, unknown> = {};
65
+ for (const [key, value] of ConstantRegistry.value.entries()) {
66
+ values[key] = DevtoolsJson.toSafe(value);
67
+ }
68
+
69
+ return { models, scalars, enums, values, primitives: PrimitiveRegistry.getNames().sort(), relations };
70
+ }
71
+
72
+ static #serializeView(modelRef: ConstantCls | undefined, modelType: ConstantType): ConstantModelView {
73
+ const fieldMeta = (modelRef as { [FIELD_META]?: Record<string, ConstantField> } | undefined)?.[FIELD_META] ?? {};
74
+ const fields: Record<string, ConstantFieldNode> = {};
75
+ for (const [name, field] of Object.entries(fieldMeta)) {
76
+ fields[name] = ConstantSerializer.#serializeField(name, field);
77
+ }
78
+ return {
79
+ modelName: modelRef ? ConstantRegistry.getModelName(modelRef) : "Unknown",
80
+ modelType,
81
+ fields,
82
+ };
83
+ }
84
+
85
+ static #serializeField(name: string, field: ConstantField): ConstantFieldNode {
86
+ const props = field.getProps();
87
+ const fieldKind = props.fieldType ?? "property";
88
+
89
+ const redacted = fieldKind === "secret";
90
+ const defaultKind = ConstantSerializer.#resolveDefaultKind(props.default);
91
+ return {
92
+ name,
93
+ fieldKind,
94
+ type: ConstantSerializer.#resolveFieldType(props),
95
+ arrDepth: props.arrDepth,
96
+ nullable: Boolean(props.nullable),
97
+ immutable: Boolean(props.immutable),
98
+ select: props.select !== false,
99
+ defaultKind: redacted && defaultKind === "value" ? "none" : defaultKind,
100
+ ...(!redacted && defaultKind === "value" ? { default: DevtoolsJson.toSafe(props.default) } : {}),
101
+ ...(props.ref ? { ref: props.ref } : {}),
102
+ ...(props.refPath ? { refPath: props.refPath } : {}),
103
+ ...(props.refType ? { refType: props.refType } : {}),
104
+ ...(props.min !== undefined ? { min: props.min } : {}),
105
+ ...(props.max !== undefined ? { max: props.max } : {}),
106
+ ...(props.minlength !== undefined ? { minlength: props.minlength } : {}),
107
+ ...(props.maxlength !== undefined ? { maxlength: props.maxlength } : {}),
108
+ ...(props.type ? { preset: props.type } : {}),
109
+ ...(props.text ? { text: props.text } : {}),
110
+ ...(props.accumulate !== undefined ? { accumulate: DevtoolsJson.toSafe(props.accumulate) } : {}),
111
+ ...(!redacted && props.example !== undefined && props.example !== null
112
+ ? { example: DevtoolsJson.toSafe(props.example) }
113
+ : {}),
114
+ ...(props.meta && Object.keys(props.meta).length
115
+ ? { meta: DevtoolsJson.toSafe(props.meta) as Record<string, unknown> }
116
+ : {}),
117
+ hasValidate: typeof props.validate === "function",
118
+ };
119
+ }
120
+
121
+ /** `default: () => dayjs()` is idiomatic here — report the factory without ever invoking it. */
122
+ static #resolveDefaultKind(value: unknown): ConstantFieldNode["defaultKind"] {
123
+ if (typeof value === "function") return "function";
124
+ if (value === null || value === undefined) return "none";
125
+ return "value";
126
+ }
127
+
128
+ static #resolveFieldType(props: ReturnType<ConstantField["getProps"]>): FieldType {
129
+ if (props.enum) return ConstantSerializer.#enumType(props.enum);
130
+ if (props.isMap) {
131
+ const [valueRef, valueArrDepth] = getNonArrayModel(props.of as Cls);
132
+ return { kind: "map", value: ConstantSerializer.#resolveModelRef(valueRef as Cls), valueArrDepth };
133
+ }
134
+ return ConstantSerializer.#resolveModelRef(props.modelRef as Cls);
135
+ }
136
+
137
+ static #enumType(enumRef: EnumInstance): FieldType {
138
+ return { kind: "enum", refName: enumRef.refName, values: [...enumRef.values] as (string | number)[] };
139
+ }
140
+
141
+ static #resolveModelRef(modelRef: Cls): FieldType {
142
+ if (isEnum(modelRef)) return ConstantSerializer.#enumType(modelRef as unknown as EnumInstance);
143
+ if (PrimitiveRegistry.has(modelRef))
144
+ return { kind: "primitive", refName: PrimitiveRegistry.getName(modelRef as typeof PrimitiveScalar) };
145
+ const refName = ConstantRegistry.getRefName(modelRef, { allowEmpty: true });
146
+ if (!refName) return { kind: "unknown", refName: modelRef.name || "Unknown" };
147
+ return {
148
+ kind: "model",
149
+ refName,
150
+ modelType: (modelRef as ConstantCls).modelType ?? "scalar",
151
+ modelName: ConstantRegistry.getModelName(modelRef),
152
+ };
153
+ }
154
+
155
+ static #collectRelations(refName: string, view: ConstantModelView): RelationEdge[] {
156
+ return Object.values(view.fields)
157
+ .filter((field) => field.type.kind === "model")
158
+ .map((field) => {
159
+ const type = field.type as Extract<FieldType, { kind: "model" }>;
160
+ return {
161
+ from: refName,
162
+ fromView: view.modelType,
163
+ field: field.name,
164
+ to: type.refName,
165
+ toView: type.modelType,
166
+ arrDepth: field.arrDepth,
167
+ nullable: field.nullable,
168
+ ...(field.refType ? { refType: field.refType } : {}),
169
+ };
170
+ });
171
+ }
172
+
173
+ static #serializeFilter(refName: string): Pick<ConstantModelNode, "filter"> {
174
+ const database = DatabaseRegistry.getDatabase(refName, { allowEmpty: true });
175
+ const filterMeta = database?.filter ? getFilterMeta(database.filter, { allowEmpty: true }) : undefined;
176
+ if (!filterMeta) return {};
177
+ const query: Record<string, FilterArg[]> = {};
178
+ for (const [key, filterInfo] of Object.entries(filterMeta.query as Record<string, FilterInfo>)) {
179
+ query[key] = (filterInfo.args ?? []).map((arg) => ConstantSerializer.#serializeFilterArg(arg));
180
+ }
181
+ return { filter: { query, sort: Object.keys(filterMeta.sort ?? {}) } };
182
+ }
183
+
184
+ static #serializeFilterArg(arg: FilterInfo["args"][number]): FilterArg {
185
+ const [single, arrDepth] = getNonArrayModel(arg.argRef as Cls);
186
+ return {
187
+ name: arg.name,
188
+ type: ConstantSerializer.#resolveModelRef(single as Cls),
189
+ arrDepth,
190
+ nullable: Boolean(arg.option?.nullable),
191
+ ...(arg.option?.ref ? { ref: arg.option.ref } : {}),
192
+ ...(arg.option?.default !== undefined ? { default: DevtoolsJson.toSafe(arg.option.default) } : {}),
193
+ };
194
+ }
195
+ }