@akanjs/devkit 3.0.0-alpha.4 → 3.0.0-alpha.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/akanContext.ts +3 -31
- package/applicationBuildRunner.ts +1 -1
- package/capacitorApp.test.ts +0 -8
- package/capacitorApp.ts +18 -112
- package/frontendBuild/allRoutesBuilder.ts +27 -8
- package/frontendBuild/cssCandidateCache.ts +3 -8
- package/frontendBuild/precompressArtifacts.ts +35 -7
- package/frontendBuild/routeClientBuilder.ts +14 -2
- package/integration/devResourceProbe.ts +7 -2
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-bang-comment-in-client.grit +20 -0
- package/package.json +2 -2
- package/qualityScanner.test.ts +154 -1
- package/qualityScanner.ts +44 -27
- package/scanInfo.ts +2 -43
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/workspaceLayout.test.ts +26 -0
- package/workspaceLayout.ts +61 -0
- package/frontendBuild/cssCandidateCache.test.ts +0 -27
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
|
|
5
|
+
export type SsrProcRole = "gateway" | "replica" | "rsc" | "other";
|
|
6
|
+
|
|
7
|
+
export interface SsrProc {
|
|
8
|
+
pid: number;
|
|
9
|
+
ppid: number;
|
|
10
|
+
rssMb: number;
|
|
11
|
+
cpuSec: number;
|
|
12
|
+
role: SsrProcRole;
|
|
13
|
+
command: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface SsrCgroupSample {
|
|
17
|
+
currentMb: number;
|
|
18
|
+
anonMb: number;
|
|
19
|
+
fileMb: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface SsrCacheSample {
|
|
23
|
+
replicaRssMb: number;
|
|
24
|
+
rscWorkerRssMb: number;
|
|
25
|
+
htmlEntries: number;
|
|
26
|
+
htmlBytes: number;
|
|
27
|
+
rscEntries: number;
|
|
28
|
+
rscBytes: number;
|
|
29
|
+
patchEntries: number;
|
|
30
|
+
patchBytes: number;
|
|
31
|
+
ssrChunkKeys: number;
|
|
32
|
+
loadedRouteModules: number;
|
|
33
|
+
fullSsr: number;
|
|
34
|
+
rscNavigation: number;
|
|
35
|
+
heap: SsrHeapSample;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* RSS minus JS heap is what a module graph costs beyond its objects — compiled code, module
|
|
40
|
+
* records, native allocator retention — so the split is what separates "the app retains objects"
|
|
41
|
+
* from "the runtime retains code". `jscExtra` is JSC's off-heap attribution, which is where
|
|
42
|
+
* typed-array backing stores (cached Flight chunks) land.
|
|
43
|
+
*/
|
|
44
|
+
export interface SsrHeapSample {
|
|
45
|
+
replicaHeapUsedMb: number;
|
|
46
|
+
replicaJscHeapMb: number;
|
|
47
|
+
replicaJscExtraMb: number;
|
|
48
|
+
workerHeapUsedMb: number;
|
|
49
|
+
workerJscHeapMb: number;
|
|
50
|
+
workerJscExtraMb: number;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface SsrMemoryProbeOptions {
|
|
54
|
+
appName: string;
|
|
55
|
+
/** Built app directory — `dist/apps/<app>` unless the build was relocated. */
|
|
56
|
+
distDir: string;
|
|
57
|
+
port: number;
|
|
58
|
+
basePaths: string;
|
|
59
|
+
repoName: string;
|
|
60
|
+
serveDomain: string;
|
|
61
|
+
scenarios: number[];
|
|
62
|
+
/** Repeat count for scenario 1 (same route) and per-route passes in scenario 6. */
|
|
63
|
+
repeats: number;
|
|
64
|
+
concurrencies: number[];
|
|
65
|
+
idleSeconds: number;
|
|
66
|
+
/** Report cadence forced on the children; also bounds how long a sample waits for a fresh one. */
|
|
67
|
+
metricsIntervalMs: number;
|
|
68
|
+
/** Disables both result caches at boot — the second half of scenario 3. */
|
|
69
|
+
cacheOff: boolean;
|
|
70
|
+
/**
|
|
71
|
+
* Forces `Bun.gc(true)` before every metrics sample. Required for the s7 ratchet test: without
|
|
72
|
+
* it, growth across passes cannot be told apart from garbage that has not been collected yet.
|
|
73
|
+
*/
|
|
74
|
+
gcOnReport: boolean;
|
|
75
|
+
/** s7: how many times to walk the whole route list. */
|
|
76
|
+
passes: number;
|
|
77
|
+
/** s8: cumulative distinct-route counts to sample the per-route slope at. */
|
|
78
|
+
sweep: number[];
|
|
79
|
+
logPath: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Production process-tree RSS and cache occupancy — probe §0.3 of
|
|
84
|
+
* `local/reduce-ssr-ram/01-measurement-harness.md`, and the source of the phase 0 results table.
|
|
85
|
+
*
|
|
86
|
+
* bun pkgs/@akanjs/devkit/integration/ssrMemoryProbe.ts <app> [--scenarios=1,2,5] [--cache=off]
|
|
87
|
+
*
|
|
88
|
+
* The production tree is gateway → replica × N → rsc worker × N, with no builder; `DevResourceProbe`
|
|
89
|
+
* measures the dev tree instead and the two are not interchangeable.
|
|
90
|
+
*
|
|
91
|
+
* Two things this exists to get right:
|
|
92
|
+
*
|
|
93
|
+
* - **Replica and worker RSS are reported separately.** They are separate processes and the worker
|
|
94
|
+
* holds a private copy of the pages bundle, so one summed number hides which of the two grows.
|
|
95
|
+
* - **On Linux it samples the cgroup's `anon` / `file` split, not just RSS.** Whether a cache is
|
|
96
|
+
* anonymous heap (an OOM contributor) or page cache (evictable) is the entire question phase 2
|
|
97
|
+
* asks; RSS cannot answer it, and neither can macOS, where the file half does not exist in the
|
|
98
|
+
* same form. Read a macOS run as directional only.
|
|
99
|
+
*/
|
|
100
|
+
export class SsrMemoryProbe {
|
|
101
|
+
static readonly #columns: SsrProcRole[] = ["gateway", "replica", "rsc"];
|
|
102
|
+
|
|
103
|
+
static parseArgs(argv: string[]): SsrMemoryProbeOptions {
|
|
104
|
+
const args = new Map(
|
|
105
|
+
argv.slice(1).map((arg) => {
|
|
106
|
+
const [key, value] = arg.replace(/^--/, "").split("=");
|
|
107
|
+
return [key ?? "", value ?? "1"];
|
|
108
|
+
}),
|
|
109
|
+
);
|
|
110
|
+
const appName = argv[0] ?? "akan";
|
|
111
|
+
const num = (key: string, fallback: number) => Number(args.get(key) ?? fallback);
|
|
112
|
+
const list = (key: string, fallback: number[]) =>
|
|
113
|
+
args.has(key)
|
|
114
|
+
? (args.get(key) ?? "")
|
|
115
|
+
.split(",")
|
|
116
|
+
.map(Number)
|
|
117
|
+
.filter((value) => Number.isFinite(value) && value > 0)
|
|
118
|
+
: fallback;
|
|
119
|
+
return {
|
|
120
|
+
appName,
|
|
121
|
+
distDir: args.get("dist") ?? path.join(process.cwd(), "dist", "apps", appName),
|
|
122
|
+
port: num("port", 8482),
|
|
123
|
+
basePaths: args.get("basePaths") ?? "",
|
|
124
|
+
repoName: args.get("repo") ?? path.basename(process.cwd()),
|
|
125
|
+
serveDomain: args.get("domain") ?? "localhost",
|
|
126
|
+
scenarios: list("scenarios", [1, 2, 5, 6]),
|
|
127
|
+
repeats: num("repeats", 200),
|
|
128
|
+
concurrencies: list("concurrency", [1, 8, 32]),
|
|
129
|
+
idleSeconds: num("idle", 300),
|
|
130
|
+
metricsIntervalMs: num("metricsInterval", 10_000),
|
|
131
|
+
cacheOff: args.get("cache") === "off",
|
|
132
|
+
gcOnReport: args.has("gc"),
|
|
133
|
+
passes: num("passes", 3),
|
|
134
|
+
sweep: list("sweep", [1, 10, 50, 169]),
|
|
135
|
+
logPath: args.get("log") ?? path.join(os.tmpdir(), `akan-ssr-memory-${appName}.log`),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
readonly #options: SsrMemoryProbeOptions;
|
|
140
|
+
#gatewayPid = 0;
|
|
141
|
+
|
|
142
|
+
constructor(options: SsrMemoryProbeOptions) {
|
|
143
|
+
this.#options = options;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async run(): Promise<void> {
|
|
147
|
+
const { appName, distDir, port, basePaths, repoName, serveDomain, logPath, cacheOff } = this.#options;
|
|
148
|
+
if (!fs.existsSync(path.join(distDir, "main.js")))
|
|
149
|
+
throw new Error(`no built app at ${distDir} — run \`akan build ${appName}\` first`);
|
|
150
|
+
await Bun.write(logPath, "");
|
|
151
|
+
const gateway = Bun.spawn(["bun", "main.js"], {
|
|
152
|
+
cwd: distDir,
|
|
153
|
+
env: {
|
|
154
|
+
...process.env,
|
|
155
|
+
NODE_ENV: "production",
|
|
156
|
+
USE_AKANJS_PKGS: "true",
|
|
157
|
+
AKAN_PUBLIC_APP_NAME: appName,
|
|
158
|
+
// `getEnv()` requires these one at a time, and a missing one fails the *replica* while the
|
|
159
|
+
// gateway stays up: the process tree still looks like a healthy three, and only the child's
|
|
160
|
+
// `ready` flag and the 503s give it away. Both are why `#waitForReady` checks child
|
|
161
|
+
// readiness rather than the gateway's own 200.
|
|
162
|
+
AKAN_PUBLIC_REPO_NAME: process.env.AKAN_PUBLIC_REPO_NAME ?? repoName,
|
|
163
|
+
AKAN_PUBLIC_SERVE_DOMAIN: process.env.AKAN_PUBLIC_SERVE_DOMAIN ?? serveDomain,
|
|
164
|
+
AKAN_PUBLIC_ENV: "local",
|
|
165
|
+
AKAN_PUBLIC_OPERATION_MODE: "local",
|
|
166
|
+
SERVER_MODE: "federation",
|
|
167
|
+
...(basePaths ? { AKAN_PUBLIC_BASE_PATHS: basePaths } : {}),
|
|
168
|
+
PORT: String(port),
|
|
169
|
+
AKAN_MEMORY_LOG: "1",
|
|
170
|
+
AKAN_MEMORY_LOG_INTERVAL_MS: String(this.#options.metricsIntervalMs),
|
|
171
|
+
...(cacheOff ? { AKAN_HTML_RESULT_CACHE: "0", AKAN_RSC_RESULT_CACHE: "0" } : {}),
|
|
172
|
+
...(this.#options.gcOnReport ? { AKAN_MEMORY_GC_ON_REPORT: "1" } : {}),
|
|
173
|
+
},
|
|
174
|
+
stdout: Bun.file(logPath),
|
|
175
|
+
stderr: Bun.file(logPath),
|
|
176
|
+
});
|
|
177
|
+
this.#gatewayPid = gateway.pid;
|
|
178
|
+
console.info(
|
|
179
|
+
`[probe] app=${appName} pid=${gateway.pid} port=${port} cache=${cacheOff ? "off" : "on"} log=${logPath}`,
|
|
180
|
+
);
|
|
181
|
+
try {
|
|
182
|
+
await this.#measure();
|
|
183
|
+
} finally {
|
|
184
|
+
await this.#cleanup(gateway);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async #measure(): Promise<void> {
|
|
189
|
+
const { scenarios, repeats, concurrencies, idleSeconds } = this.#options;
|
|
190
|
+
if (!(await this.#waitForReady(180_000)))
|
|
191
|
+
throw new Error(`no replica became ready within 180s — see ${this.#options.logPath}`);
|
|
192
|
+
// Route modules are evaluated on first request, so nothing before this line is a floor.
|
|
193
|
+
await Bun.sleep(5_000);
|
|
194
|
+
|
|
195
|
+
console.info(`\n${"sample".padEnd(24)} ${["gway", "repl", "rsc"].map((h) => h.padStart(6)).join(" ")}`);
|
|
196
|
+
await this.#sample("boot");
|
|
197
|
+
|
|
198
|
+
const routes = await this.#staticRoutes();
|
|
199
|
+
console.info(`[probe] ${routes.length} static route(s) resolved`);
|
|
200
|
+
|
|
201
|
+
if (scenarios.includes(1)) {
|
|
202
|
+
const route = routes[0] ?? "/";
|
|
203
|
+
await this.#run(
|
|
204
|
+
`s1 same-route x${repeats}`,
|
|
205
|
+
Array.from({ length: repeats }, () => route),
|
|
206
|
+
1,
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (scenarios.includes(2)) await this.#run("s2 every-route x1", routes, 1);
|
|
211
|
+
|
|
212
|
+
if (scenarios.includes(4)) {
|
|
213
|
+
await this.#run(
|
|
214
|
+
"s4 rsc-only",
|
|
215
|
+
routes.map((route) => `/__rsc?url=${encodeURIComponent(route)}`),
|
|
216
|
+
1,
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (scenarios.includes(6)) {
|
|
221
|
+
for (const concurrency of concurrencies) await this.#run(`s6 concurrency=${concurrency}`, routes, concurrency);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// s7 — ratchet vs working set. Every pass renders the same routes, so pass 1 pays for module
|
|
225
|
+
// evaluation and later passes pay for nothing new. Flat later passes mean the memory is a
|
|
226
|
+
// working set and only a smaller bundle or fewer replicas reduces it; continued growth means
|
|
227
|
+
// per-render retention, which is a bug worth more than any ceiling. Run with `--cache=off --gc`
|
|
228
|
+
// or it measures cache hits and uncollected garbage instead.
|
|
229
|
+
if (scenarios.includes(7)) {
|
|
230
|
+
if (!this.#options.cacheOff)
|
|
231
|
+
console.info("[probe] WARNING s7 without --cache=off measures cache hits from pass 2 on");
|
|
232
|
+
if (!this.#options.gcOnReport)
|
|
233
|
+
console.info("[probe] WARNING s7 without --gc cannot separate retention from uncollected garbage");
|
|
234
|
+
for (let pass = 1; pass <= this.#options.passes; pass += 1)
|
|
235
|
+
await this.#run(`s7 pass ${pass}/${this.#options.passes}`, routes, 1);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// s8 — per-route slope vs fixed intercept. Cumulative: each step adds routes the process has
|
|
239
|
+
// not seen, so the RSS series against distinct-route count separates the two.
|
|
240
|
+
if (scenarios.includes(8)) {
|
|
241
|
+
for (const count of this.#options.sweep) {
|
|
242
|
+
const subset = routes.slice(0, Math.min(count, routes.length));
|
|
243
|
+
if (subset.length === 0) continue;
|
|
244
|
+
await this.#run(`s8 routes<=${subset.length}`, subset, 1);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
if (scenarios.includes(5)) {
|
|
249
|
+
const started = Date.now();
|
|
250
|
+
while ((Date.now() - started) / 1_000 < idleSeconds) {
|
|
251
|
+
await Bun.sleep(30_000);
|
|
252
|
+
await this.#sample(`s5 idle+${Math.round((Date.now() - started) / 1_000)}s`);
|
|
253
|
+
}
|
|
254
|
+
console.info("[probe] idle reclamation is the delta from the last pre-idle row; expect none today");
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* A scenario is only a measurement if its requests succeeded. A gateway whose replica died still
|
|
260
|
+
* answers — with 503s, instantly — and the resulting RSS row looks like a legitimately cheap one.
|
|
261
|
+
* Fail loudly rather than publish that number.
|
|
262
|
+
*/
|
|
263
|
+
async #run(label: string, urls: string[], concurrency: number): Promise<void> {
|
|
264
|
+
const started = Date.now();
|
|
265
|
+
const { ok, failed, statuses } = await this.#browse(urls, concurrency);
|
|
266
|
+
const elapsed = ((Date.now() - started) / 1_000).toFixed(1);
|
|
267
|
+
const breakdown = [...statuses.entries()].map(([status, count]) => `${status}×${count}`).join(" ");
|
|
268
|
+
console.info(`[probe] ${label}: ok=${ok} failed=${failed} in ${elapsed}s (${breakdown})`);
|
|
269
|
+
if (ok === 0) throw new Error(`${label}: every request failed (${breakdown}) — the sample would be meaningless`);
|
|
270
|
+
if (failed > 0) console.info(`[probe] WARNING ${label} has ${failed} failed request(s); the row below is partial`);
|
|
271
|
+
await this.#sample(label, Date.now());
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
async #sample(label: string, since = Date.now()): Promise<void> {
|
|
275
|
+
const procs = await this.#sampleTree();
|
|
276
|
+
const byRole = new Map<SsrProcRole, number>();
|
|
277
|
+
for (const proc of procs) byRole.set(proc.role, (byRole.get(proc.role) ?? 0) + proc.rssMb);
|
|
278
|
+
const total = procs.reduce((sum, proc) => sum + proc.rssMb, 0);
|
|
279
|
+
const mb = (value: number) => value.toFixed(0).padStart(6);
|
|
280
|
+
const cells = SsrMemoryProbe.#columns.map((role) => (byRole.has(role) ? mb(byRole.get(role) ?? 0) : " —"));
|
|
281
|
+
const cgroup = SsrMemoryProbe.readCgroupSample();
|
|
282
|
+
console.info(
|
|
283
|
+
`${label.padEnd(24)} ${cells.join(" ")} | total ${mb(total)}MB n=${procs.length}` +
|
|
284
|
+
(cgroup
|
|
285
|
+
? ` | cgroup ${cgroup.currentMb.toFixed(0)}MB anon=${cgroup.anonMb.toFixed(0)} file=${cgroup.fileMb.toFixed(0)}`
|
|
286
|
+
: ""),
|
|
287
|
+
);
|
|
288
|
+
const cache = await this.#sampleCaches(since);
|
|
289
|
+
if (!cache) return;
|
|
290
|
+
console.info(
|
|
291
|
+
`${"".padEnd(24)} caches: html=${cache.htmlEntries}/${SsrMemoryProbe.#mib(cache.htmlBytes)} ` +
|
|
292
|
+
`rsc=${cache.rscEntries}/${SsrMemoryProbe.#mib(cache.rscBytes)} ` +
|
|
293
|
+
`patch=${cache.patchEntries}/${SsrMemoryProbe.#mib(cache.patchBytes)} ` +
|
|
294
|
+
`ssrChunkKeys=${cache.ssrChunkKeys} routeModules=${cache.loadedRouteModules} ` +
|
|
295
|
+
`req=${cache.fullSsr}ssr/${cache.rscNavigation}rsc`,
|
|
296
|
+
);
|
|
297
|
+
const { heap } = cache;
|
|
298
|
+
const n = (value: number) => value.toFixed(0);
|
|
299
|
+
console.info(
|
|
300
|
+
`${"".padEnd(24)} heap: repl heapUsed=${n(heap.replicaHeapUsedMb)} jsc=${n(heap.replicaJscHeapMb)} ` +
|
|
301
|
+
`jscExtra=${n(heap.replicaJscExtraMb)} | rsc heapUsed=${n(heap.workerHeapUsedMb)} ` +
|
|
302
|
+
`jsc=${n(heap.workerJscHeapMb)} jscExtra=${n(heap.workerJscExtraMb)} (MB)`,
|
|
303
|
+
);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
static #mib(bytes: number): string {
|
|
307
|
+
return `${(bytes / 1024 / 1024).toFixed(1)}MiB`;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Reads the container's own accounting. `memory.current` counts page cache, so a disk-backed
|
|
312
|
+
* cache still shows up here — but under `file`, which the kernel reclaims under pressure instead
|
|
313
|
+
* of OOM-killing. That split is what phase 2 has to move. Returns null off cgroup v2.
|
|
314
|
+
*/
|
|
315
|
+
static readCgroupSample(): SsrCgroupSample | null {
|
|
316
|
+
try {
|
|
317
|
+
const current = Number.parseInt(fs.readFileSync("/sys/fs/cgroup/memory.current", "utf8").trim(), 10);
|
|
318
|
+
if (!Number.isFinite(current)) return null;
|
|
319
|
+
const stat = fs.readFileSync("/sys/fs/cgroup/memory.stat", "utf8");
|
|
320
|
+
const field = (name: string) => {
|
|
321
|
+
const parsed = Number.parseInt(new RegExp(`^${name} (\\d+)$`, "m").exec(stat)?.[1] ?? "", 10);
|
|
322
|
+
return Number.isFinite(parsed) ? parsed : 0;
|
|
323
|
+
};
|
|
324
|
+
const toMb = (bytes: number) => bytes / 1024 / 1024;
|
|
325
|
+
return { currentMb: toMb(current), anonMb: toMb(field("anon")), fileMb: toMb(field("file")) };
|
|
326
|
+
} catch {
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Waits for a metrics report sampled *after* `since` before reading the cache columns.
|
|
333
|
+
*
|
|
334
|
+
* The counters ride a periodic IPC report, so reading immediately after a browse returns the
|
|
335
|
+
* state from before it — and the two-hop path (worker → replica → gateway) means the `rsc*` and
|
|
336
|
+
* `rscWorker*` fields can be a further interval behind. Without this wait every row silently
|
|
337
|
+
* attributes one scenario's cache growth to the previous scenario.
|
|
338
|
+
*/
|
|
339
|
+
async #sampleCaches(since: number): Promise<SsrCacheSample | null> {
|
|
340
|
+
const deadline = Date.now() + this.#options.metricsIntervalMs * 3 + 5_000;
|
|
341
|
+
let children: Array<Record<string, number>> = [];
|
|
342
|
+
let fresh = false;
|
|
343
|
+
while (Date.now() < deadline) {
|
|
344
|
+
children = await this.#fetchChildMetrics();
|
|
345
|
+
// Both hops must have re-reported: `reportedAt` is the replica's own sample time, and
|
|
346
|
+
// `rscWorkerReportedAt` the worker's as of the replica's last read of it.
|
|
347
|
+
fresh =
|
|
348
|
+
children.length > 0 &&
|
|
349
|
+
children.every(
|
|
350
|
+
(metrics) => Number(metrics.reportedAt ?? 0) >= since && Number(metrics.rscWorkerReportedAt ?? 0) >= since,
|
|
351
|
+
);
|
|
352
|
+
if (fresh) break;
|
|
353
|
+
await Bun.sleep(500);
|
|
354
|
+
}
|
|
355
|
+
if (children.length === 0) return null;
|
|
356
|
+
if (!fresh) console.info(`${"".padEnd(24)} WARNING cache columns are stale — no fresh report within the wait`);
|
|
357
|
+
{
|
|
358
|
+
const sum = (key: string) => children.reduce((total, metrics) => total + Number(metrics[key] ?? 0), 0);
|
|
359
|
+
const mb = (key: string) => sum(key) / 1024 / 1024;
|
|
360
|
+
return {
|
|
361
|
+
replicaRssMb: sum("rssBytes") / 1024 / 1024,
|
|
362
|
+
rscWorkerRssMb: sum("rscWorkerRssBytes") / 1024 / 1024,
|
|
363
|
+
htmlEntries: sum("httpHtmlCacheEntries"),
|
|
364
|
+
htmlBytes: sum("httpHtmlCacheBytes"),
|
|
365
|
+
rscEntries: sum("rscResultCacheEntries"),
|
|
366
|
+
rscBytes: sum("rscResultCacheBytes"),
|
|
367
|
+
patchEntries: sum("rscPatchResultCacheEntries"),
|
|
368
|
+
patchBytes: sum("rscPatchResultCacheBytes"),
|
|
369
|
+
ssrChunkKeys: sum("ssrChunkRegistrySize"),
|
|
370
|
+
loadedRouteModules: sum("rscLoadedRouteModuleCount"),
|
|
371
|
+
fullSsr: sum("httpFullSsrCount"),
|
|
372
|
+
rscNavigation: sum("httpRscNavigationCount"),
|
|
373
|
+
heap: {
|
|
374
|
+
replicaHeapUsedMb: mb("heapUsedBytes"),
|
|
375
|
+
replicaJscHeapMb: mb("jscHeapSizeBytes"),
|
|
376
|
+
replicaJscExtraMb: mb("jscExtraMemorySizeBytes"),
|
|
377
|
+
workerHeapUsedMb: mb("rscWorkerHeapUsedBytes"),
|
|
378
|
+
workerJscHeapMb: mb("rscWorkerJscHeapSizeBytes"),
|
|
379
|
+
workerJscExtraMb: mb("rscWorkerJscExtraMemorySizeBytes"),
|
|
380
|
+
},
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
async #fetchChildMetrics(): Promise<Array<Record<string, number>>> {
|
|
386
|
+
try {
|
|
387
|
+
const res = await fetch(`http://localhost:${this.#options.port}/_akan/app/metrics`, {
|
|
388
|
+
signal: AbortSignal.timeout(5_000),
|
|
389
|
+
});
|
|
390
|
+
if (!res.ok) return [];
|
|
391
|
+
const body = (await res.json()) as { children?: Array<{ metrics?: Record<string, number> }> };
|
|
392
|
+
return (body.children ?? []).map((child) => child.metrics ?? {});
|
|
393
|
+
} catch {
|
|
394
|
+
return [];
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
#roleOf(command: string): SsrProcRole {
|
|
399
|
+
if (/rscWorker\.js|react-server/.test(command)) return "rsc";
|
|
400
|
+
if (/\bmain\.js\b/.test(command)) return "gateway";
|
|
401
|
+
if (/server\.js|bun -e|import\(/.test(command)) return "replica";
|
|
402
|
+
return "other";
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
/** One `ps`, then walk from the gateway pid to a fixpoint. */
|
|
406
|
+
async #sampleTree(): Promise<SsrProc[]> {
|
|
407
|
+
const proc = Bun.spawn(["ps", "-eo", "pid=,ppid=,rss=,time=,command="], { stdout: "pipe" });
|
|
408
|
+
const text = await new Response(proc.stdout).text();
|
|
409
|
+
await proc.exited;
|
|
410
|
+
const all = text
|
|
411
|
+
.split("\n")
|
|
412
|
+
.map((line) => line.trim())
|
|
413
|
+
.filter(Boolean)
|
|
414
|
+
.map((line) => {
|
|
415
|
+
const match = /^(\d+)\s+(\d+)\s+(\d+)\s+([\d:.]+)\s+(.*)$/.exec(line);
|
|
416
|
+
if (!match) return null;
|
|
417
|
+
const [, pid, ppid, rss, time, command] = match;
|
|
418
|
+
const parts = (time ?? "0:0").split(":");
|
|
419
|
+
const cpuSec =
|
|
420
|
+
parts.length === 3
|
|
421
|
+
? Number(parts[0]) * 3600 + Number(parts[1]) * 60 + Number(parts[2])
|
|
422
|
+
: Number(parts[0] ?? 0) * 60 + Number(parts[1] ?? 0);
|
|
423
|
+
return {
|
|
424
|
+
pid: Number(pid),
|
|
425
|
+
ppid: Number(ppid),
|
|
426
|
+
rssMb: Number(rss) / 1024,
|
|
427
|
+
cpuSec,
|
|
428
|
+
role: this.#roleOf(command ?? ""),
|
|
429
|
+
command: command ?? "",
|
|
430
|
+
} satisfies SsrProc;
|
|
431
|
+
})
|
|
432
|
+
.filter((proc): proc is SsrProc => proc !== null);
|
|
433
|
+
const kept = new Map<number, SsrProc>();
|
|
434
|
+
const root = all.find((proc) => proc.pid === this.#gatewayPid);
|
|
435
|
+
if (root) kept.set(this.#gatewayPid, root);
|
|
436
|
+
for (let pass = 0; pass < 12; pass++) {
|
|
437
|
+
const before = kept.size;
|
|
438
|
+
for (const proc of all) if (kept.has(proc.ppid) && !kept.has(proc.pid)) kept.set(proc.pid, proc);
|
|
439
|
+
if (kept.size === before) break;
|
|
440
|
+
}
|
|
441
|
+
return [...kept.values()].filter((proc) => proc.role !== "other" || proc.pid === this.#gatewayPid);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Concrete urls from the built route seed index. `:lang` takes the default locale; any route with
|
|
446
|
+
* another `:param` is skipped because it needs a real id to render.
|
|
447
|
+
*/
|
|
448
|
+
async #staticRoutes(): Promise<string[]> {
|
|
449
|
+
const artifactDir = path.join(this.#options.distDir, ".akan", "artifact");
|
|
450
|
+
const seed = (await Bun.file(path.join(artifactDir, "route-seed-index.json")).json()) as {
|
|
451
|
+
entries: Array<{ routeId: string }>;
|
|
452
|
+
};
|
|
453
|
+
const artifact = (await Bun.file(path.join(artifactDir, "base-artifact.json")).json()) as {
|
|
454
|
+
i18n?: { defaultLocale?: string };
|
|
455
|
+
};
|
|
456
|
+
const locale = artifact.i18n?.defaultLocale ?? "en";
|
|
457
|
+
const urls = new Set<string>();
|
|
458
|
+
for (const entry of seed.entries) {
|
|
459
|
+
const url = entry.routeId.replace(/:lang\b/g, locale);
|
|
460
|
+
if (/[:[]/.test(url)) continue;
|
|
461
|
+
urls.add(url.startsWith("/") ? url : `/${url}`);
|
|
462
|
+
}
|
|
463
|
+
return [...urls].sort();
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
async #browse(
|
|
467
|
+
urls: string[],
|
|
468
|
+
concurrency: number,
|
|
469
|
+
): Promise<{ ok: number; failed: number; statuses: Map<string, number> }> {
|
|
470
|
+
let ok = 0;
|
|
471
|
+
let failed = 0;
|
|
472
|
+
let cursor = 0;
|
|
473
|
+
const statuses = new Map<string, number>();
|
|
474
|
+
const record = (key: string) => statuses.set(key, (statuses.get(key) ?? 0) + 1);
|
|
475
|
+
const worker = async () => {
|
|
476
|
+
while (cursor < urls.length) {
|
|
477
|
+
const url = urls[cursor++];
|
|
478
|
+
if (url === undefined) return;
|
|
479
|
+
try {
|
|
480
|
+
const res = await fetch(`http://localhost:${this.#options.port}${url}`, {
|
|
481
|
+
signal: AbortSignal.timeout(60_000),
|
|
482
|
+
});
|
|
483
|
+
// Drain the body: SSR streams, so a response is not rendered until it is read.
|
|
484
|
+
await res.arrayBuffer();
|
|
485
|
+
record(String(res.status));
|
|
486
|
+
if (res.ok) ok++;
|
|
487
|
+
else failed++;
|
|
488
|
+
} catch (error) {
|
|
489
|
+
record(error instanceof Error ? error.name : "error");
|
|
490
|
+
failed++;
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
};
|
|
494
|
+
await Promise.all(Array.from({ length: Math.max(1, concurrency) }, worker));
|
|
495
|
+
return { ok, failed, statuses };
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/**
|
|
499
|
+
* Waits for a **child** to report ready, not for the gateway to answer. The gateway binds and
|
|
500
|
+
* serves `/health` happily while every replica is in a crash-restart loop, so a 200 here proves
|
|
501
|
+
* nothing about whether anything can render.
|
|
502
|
+
*/
|
|
503
|
+
async #waitForReady(timeoutMs: number): Promise<boolean> {
|
|
504
|
+
const deadline = Date.now() + timeoutMs;
|
|
505
|
+
let lastError = "";
|
|
506
|
+
while (Date.now() < deadline) {
|
|
507
|
+
try {
|
|
508
|
+
const res = await fetch(`http://localhost:${this.#options.port}/_akan/app/health`, {
|
|
509
|
+
signal: AbortSignal.timeout(2_000),
|
|
510
|
+
});
|
|
511
|
+
if (res.ok) {
|
|
512
|
+
const body = (await res.json()) as {
|
|
513
|
+
children?: Array<{ ready?: boolean; role?: string; lastErrorMessage?: string }>;
|
|
514
|
+
};
|
|
515
|
+
const children = body.children ?? [];
|
|
516
|
+
if (children.some((child) => child.ready && child.role !== "batch")) return true;
|
|
517
|
+
lastError = children.find((child) => child.lastErrorMessage)?.lastErrorMessage ?? lastError;
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
// The gateway binds before the replicas are up; keep polling until one answers.
|
|
521
|
+
}
|
|
522
|
+
await Bun.sleep(1_000);
|
|
523
|
+
}
|
|
524
|
+
if (lastError) console.info(`[probe] no child became ready; last child error: ${lastError}`);
|
|
525
|
+
return false;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
async #cleanup(gateway: { kill: (signal: NodeJS.Signals) => void }): Promise<void> {
|
|
529
|
+
for (const proc of (await this.#sampleTree()).reverse()) {
|
|
530
|
+
try {
|
|
531
|
+
process.kill(proc.pid, "SIGKILL");
|
|
532
|
+
} catch {}
|
|
533
|
+
}
|
|
534
|
+
try {
|
|
535
|
+
gateway.kill("SIGKILL");
|
|
536
|
+
} catch {}
|
|
537
|
+
await Bun.sleep(1_000);
|
|
538
|
+
console.info(`[probe] survivors after kill: ${(await this.#sampleTree()).length}`);
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (import.meta.main) await new SsrMemoryProbe(SsrMemoryProbe.parseArgs(process.argv.slice(2))).run();
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Bun's bundler classifies `//!` and `/*!` as legal comments (the `@license` / `@preserve` class) and keeps
|
|
5
|
+
// them through `minify: true`, so a `//!` marker in browser-reachable code ships verbatim to every visitor.
|
|
6
|
+
// `legalComments` is not a Bun.build option, so the source is the only place to stop it.
|
|
7
|
+
// The two alternatives anchor the marker to line start or to whitespace after code, which keeps a literal
|
|
8
|
+
// like `'https://host//!path'` from tripping the rule. A marker on the file's very first line is trivia that
|
|
9
|
+
// Biome does not expose to the pattern, so it is the one case this rule cannot see.
|
|
10
|
+
JsModule() as $mod where {
|
|
11
|
+
or {
|
|
12
|
+
$mod <: r"(?m)(?s).*^[ \t]*//!.*",
|
|
13
|
+
$mod <: r"(?s).*[^\s/][ \t]+//!.*"
|
|
14
|
+
},
|
|
15
|
+
register_diagnostic(
|
|
16
|
+
span = $mod,
|
|
17
|
+
message = "The `//!` marker survives minification (Bun keeps it as a legal comment) and ships to the browser. Use `// FIXME:` or `// TODO:` in client-reachable code; keep `//!` for server, srvkit, and CLI files.",
|
|
18
|
+
severity = "error"
|
|
19
|
+
)
|
|
20
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "3.0.0-alpha.
|
|
3
|
+
"version": "3.0.0-alpha.5",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -44,7 +44,7 @@
|
|
|
44
44
|
"@langchain/openai": "^1.4.6",
|
|
45
45
|
"@tailwindcss/node": "^4.3.0",
|
|
46
46
|
"@trapezedev/project": "^7.1.4",
|
|
47
|
-
"akanjs": "3.0.0-alpha.
|
|
47
|
+
"akanjs": "3.0.0-alpha.5",
|
|
48
48
|
"chalk": "^5.6.2",
|
|
49
49
|
"commander": "^14.0.3",
|
|
50
50
|
"dayjs": "^1.11.20",
|