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.
- package/client/device.ts +1 -1
- package/client/router.ts +12 -3
- package/dictionary/dictionaryRegistry.ts +87 -0
- package/dictionary/index.ts +1 -0
- package/dictionary/trans.ts +2 -0
- package/package.json +11 -1
- package/server/SSR_MEMORY_DIAGNOSIS.md +47 -0
- package/server/akanApp.ts +2 -2
- package/server/akanServer.ts +27 -13
- package/server/artifact/builderRpc.ts +51 -7
- package/server/artifact/ipcTypes.ts +40 -2
- 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/memoryLimit.ts +77 -0
- package/server/resolver/signal.resolver.ts +2 -2
- package/server/rscWorkerHost.ts +12 -48
- 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 +41 -1
- 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/memoryLimit.d.ts +26 -0
- package/types/server/resolver/signal.resolver.d.ts +6 -0
- package/types/ui/System/frameCssVars.d.ts +1 -1
- package/types/webkit/useFrameRuntime.d.ts +1 -1
- package/ui/Layout/TopInset.tsx +10 -13
- package/ui/ServerPortal.tsx +1 -1
- package/ui/System/CSR.tsx +30 -34
- package/ui/System/Client.tsx +4 -5
- package/ui/System/SSR.tsx +70 -70
- package/ui/System/frameCssVars.ts +1 -1
- package/webkit/useCsrValues.ts +58 -27
- 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 = (
|
|
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(
|
|
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)
|
|
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
|
+
}
|
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.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
|
-
|
|
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
|
-
|
|
1216
|
+
for (;;) {
|
|
1217
1217
|
const newlineIdx = buffered.indexOf("\n");
|
|
1218
1218
|
if (newlineIdx === -1) break;
|
|
1219
1219
|
const line = buffered.slice(0, newlineIdx + 1);
|
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
|
|
|
@@ -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
|
+
}
|