akanjs 2.4.1-rc.3 → 2.4.1-rc.5

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 (36) hide show
  1. package/base/baseEnv.ts +5 -3
  2. package/dictionary/dictionaryRegistry.ts +87 -0
  3. package/dictionary/index.ts +1 -0
  4. package/dictionary/trans.ts +2 -0
  5. package/package.json +6 -1
  6. package/server/SSR_MEMORY_DIAGNOSIS.md +31 -0
  7. package/server/akanServer.ts +27 -13
  8. package/server/artifact/builderRpc.ts +51 -7
  9. package/server/artifact/ipcTypes.ts +10 -5
  10. package/server/devtools/README.md +670 -0
  11. package/server/devtools/constantSerializer.ts +195 -0
  12. package/server/devtools/depsSerializer.ts +250 -0
  13. package/server/devtools/devtoolsJson.ts +81 -0
  14. package/server/devtools/devtoolsRouter.ts +148 -0
  15. package/server/devtools/index.ts +6 -0
  16. package/server/devtools/signalSerializer.ts +338 -0
  17. package/server/devtools/types.ts +286 -0
  18. package/server/di/diLifecycle.ts +20 -0
  19. package/server/index.ts +1 -0
  20. package/server/resolver/signal.resolver.ts +2 -2
  21. package/types/dictionary/base.dictionary.d.ts +1 -1
  22. package/types/dictionary/dictionary.d.ts +8 -8
  23. package/types/dictionary/dictionaryRegistry.d.ts +26 -0
  24. package/types/dictionary/index.d.ts +1 -0
  25. package/types/server/artifact/ipcTypes.d.ts +9 -5
  26. package/types/server/devtools/constantSerializer.d.ts +11 -0
  27. package/types/server/devtools/depsSerializer.d.ts +25 -0
  28. package/types/server/devtools/devtoolsJson.d.ts +13 -0
  29. package/types/server/devtools/devtoolsRouter.d.ts +33 -0
  30. package/types/server/devtools/index.d.ts +6 -0
  31. package/types/server/devtools/signalSerializer.d.ts +21 -0
  32. package/types/server/devtools/types.d.ts +292 -0
  33. package/types/server/devtools.d.ts +1 -0
  34. package/types/server/di/diLifecycle.d.ts +12 -1
  35. package/types/server/index.d.ts +1 -0
  36. package/types/server/resolver/signal.resolver.d.ts +6 -0
package/base/baseEnv.ts CHANGED
@@ -128,9 +128,11 @@ export const getEnv = (): ClientEnv => {
128
128
  ? (window.location.host.split(":")[0] ?? "unknown")
129
129
  : "localhost");
130
130
 
131
- const serverPort = parseInt(
132
- process.env.AKAN_PUBLIC_SERVER_PORT ?? (operationMode === "local" || side === "server" ? "8282" : "443"),
133
- );
131
+ const serverPort =
132
+ side === "server"
133
+ ? parseInt(process.env.AKAN_PUBLIC_SERVER_PORT ?? (operationMode === "local" ? "8282" : "443"))
134
+ : parseInt(window.location.port || (window.location.protocol === "https:" ? "443" : "80"));
135
+
134
136
  const serverHttpProtocol: "http:" | "https:" =
135
137
  (process.env.SERVER_HTTP_PROTOCOL as "http:" | "https:" | undefined) ??
136
138
  (operationMode === "local"
@@ -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.3",
3
+ "version": "2.4.1-rc.5",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -170,6 +170,11 @@
170
170
  "types": "./types/server/memoryLimit.d.ts",
171
171
  "import": "./server/memoryLimit.ts",
172
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"
173
178
  }
174
179
  },
175
180
  "dependencies": {
@@ -121,3 +121,34 @@ Both resolve through `MemoryLimit.resolveMaxRssBytes`, so `AKAN_MEMORY_LIMIT` or
121
121
  applies (the builder takes 35% of it, the RSC worker 55%). Look for `recycling builder pid=…` followed
122
122
  by `exiting for recycle` and `announced boot state after recycle`; the last line is what re-points a
123
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`.
@@ -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
 
@@ -98,10 +98,14 @@ export interface PagesBundlePayload {
98
98
  }
99
99
 
100
100
  /**
101
- * `Bun.build` retains native bundler arenas the process never returns — `Bun.gc(true)` reclaims
102
- * nothing and the JS heap stays flat while RSS climbs so the builder's memory only comes back when
103
- * it exits. It reports its own RSS here whenever its queues drain, and the dev host recycles it past
104
- * a ceiling to turn unbounded growth into a bounded sawtooth.
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`.
105
109
  */
106
110
  export interface BuilderMetrics {
107
111
  rssBytes: number;
@@ -112,7 +116,8 @@ export interface BuilderMetrics {
112
116
  }
113
117
 
114
118
  export type BuilderEvent =
115
- | { type: "builder-ready"; buildId: string }
119
+
120
+ | { type: "builder-ready"; buildId?: string }
116
121
  | { type: "backend-ready"; pid: number }
117
122
  | {
118
123
  type: "invalidate";