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.
- package/base/baseEnv.ts +5 -3
- package/dictionary/dictionaryRegistry.ts +87 -0
- package/dictionary/index.ts +1 -0
- package/dictionary/trans.ts +2 -0
- package/package.json +6 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +31 -0
- package/server/akanServer.ts +27 -13
- package/server/artifact/builderRpc.ts +51 -7
- package/server/artifact/ipcTypes.ts +10 -5
- package/server/devtools/README.md +670 -0
- package/server/devtools/constantSerializer.ts +195 -0
- package/server/devtools/depsSerializer.ts +250 -0
- package/server/devtools/devtoolsJson.ts +81 -0
- package/server/devtools/devtoolsRouter.ts +148 -0
- package/server/devtools/index.ts +6 -0
- package/server/devtools/signalSerializer.ts +338 -0
- package/server/devtools/types.ts +286 -0
- package/server/di/diLifecycle.ts +20 -0
- package/server/index.ts +1 -0
- package/server/resolver/signal.resolver.ts +2 -2
- package/types/dictionary/base.dictionary.d.ts +1 -1
- package/types/dictionary/dictionary.d.ts +8 -8
- package/types/dictionary/dictionaryRegistry.d.ts +26 -0
- package/types/dictionary/index.d.ts +1 -0
- package/types/server/artifact/ipcTypes.d.ts +9 -5
- package/types/server/devtools/constantSerializer.d.ts +11 -0
- package/types/server/devtools/depsSerializer.d.ts +25 -0
- package/types/server/devtools/devtoolsJson.d.ts +13 -0
- package/types/server/devtools/devtoolsRouter.d.ts +33 -0
- package/types/server/devtools/index.d.ts +6 -0
- package/types/server/devtools/signalSerializer.d.ts +21 -0
- package/types/server/devtools/types.d.ts +292 -0
- package/types/server/devtools.d.ts +1 -0
- package/types/server/di/diLifecycle.d.ts +12 -1
- package/types/server/index.d.ts +1 -0
- 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 =
|
|
132
|
-
|
|
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
|
+
}
|
package/dictionary/index.ts
CHANGED
package/dictionary/trans.ts
CHANGED
|
@@ -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
|
+
"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`.
|
package/server/akanServer.ts
CHANGED
|
@@ -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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
100
|
-
this.#
|
|
101
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
102
|
-
*
|
|
103
|
-
*
|
|
104
|
-
* a ceiling to
|
|
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
|
-
|
|
119
|
+
|
|
120
|
+
| { type: "builder-ready"; buildId?: string }
|
|
116
121
|
| { type: "backend-ready"; pid: number }
|
|
117
122
|
| {
|
|
118
123
|
type: "invalidate";
|