@akanjs/devkit 3.0.0-alpha.0 → 3.0.0-alpha.2

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.
@@ -285,9 +285,14 @@ export class DevResourceProbe {
285
285
  "rscWorkerRecycleCount",
286
286
  "httpFullSsrCount",
287
287
  ];
288
- const rssMb = (Number(child.rssBytes ?? 0) / 1024 / 1024).toFixed(0);
288
+ // `rssBytes` is the replica's own; the RSC worker is a separate process reporting under
289
+ // `rscWorker*`. These used to be the same field, because the worker's report shadowed the
290
+ // replica's — so this line printed the worker's RSS labelled as the child's.
291
+ const toMb = (bytes: unknown) => (Number(bytes ?? 0) / 1024 / 1024).toFixed(0);
289
292
  const parts = keys.map((key) => `${key}=${child[key] ?? "?"}`);
290
- console.info(`[metrics ${label}] rsc rss=${rssMb}MB ${parts.join(" ")}`);
293
+ console.info(
294
+ `[metrics ${label}] replicaRss=${toMb(child.rssBytes)}MB rscWorkerRss=${toMb(child.rscWorkerRssBytes)}MB ${parts.join(" ")}`,
295
+ );
291
296
  }
292
297
 
293
298
  async #waitForLog(pattern: RegExp, timeoutMs: number): Promise<boolean> {
@@ -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();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "3.0.0-alpha.0",
3
+ "version": "3.0.0-alpha.2",
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.0",
47
+ "akanjs": "3.0.0-alpha.2",
48
48
  "chalk": "^5.6.2",
49
49
  "commander": "^14.0.3",
50
50
  "dayjs": "^1.11.20",
@@ -3,7 +3,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import { AbstractDoc } from "./abstractDoc";
6
- import { AkanQualityScanner } from "./qualityScanner";
6
+ import { AkanQualityScanner, type QualityScanResult } from "./qualityScanner";
7
7
 
8
8
  const tempRoots: string[] = [];
9
9
 
@@ -44,3 +44,142 @@ describe("AkanQualityScanner abstract rule", () => {
44
44
  expect(warnings[0]?.fix).toContain("akan compact");
45
45
  });
46
46
  });
47
+
48
+ const staticMarkup = (elementNum: number) =>
49
+ Array.from({ length: elementNum }, (_, idx) => ` <p className="text-sm">row ${idx}</p>`).join("\n");
50
+
51
+ const rulesOf = (result: QualityScanResult, rule: string) => result.warnings.filter((warning) => warning.rule === rule);
52
+
53
+ describe("AkanQualityScanner ssr rules", () => {
54
+ test("flags a client file that uses no client-only capability", async () => {
55
+ const root = await makeWorkspace({
56
+ "apps/demo/ui/Plain.tsx": `"use client";\nexport const Plain = () => <div>plain</div>;\n`,
57
+ "apps/demo/ui/Interactive.tsx": `"use client";\nexport const Interactive = () => <button onClick={() => null}>go</button>;\n`,
58
+ "apps/demo/ui/Hooked.tsx": `"use client";\nimport { useState } from "react";\nexport const Hooked = () => {\n const [open] = useState(false);\n return <div>{open ? "y" : "n"}</div>;\n};\n`,
59
+ });
60
+
61
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client");
62
+
63
+ expect(warnings).toHaveLength(1);
64
+ expect(warnings[0]?.file).toBe("apps/demo/ui/Plain.tsx");
65
+ });
66
+
67
+ test("keeps the directive on a third-party wrapper and on an index_ boundary", async () => {
68
+ const root = await makeWorkspace({
69
+ "apps/demo/ui/Chart.tsx": `"use client";\nimport { Bar } from "react-chartjs-2";\nexport const Chart = () => <Bar data={{}} />;\n`,
70
+ "apps/demo/ui/Lazy/index_.tsx": `"use client";\nexport { Inner } from "./Inner";\n`,
71
+ });
72
+
73
+ expect(rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.unnecessary-use-client")).toHaveLength(0);
74
+ });
75
+
76
+ test("flags a static component and a mostly-static component inside a client file", async () => {
77
+ const root = await makeWorkspace({
78
+ "apps/demo/ui/Panels.tsx": [
79
+ `"use client";`,
80
+ `import { useState } from "react";`,
81
+ `export const StaticPanel = () => (`,
82
+ ` <section>`,
83
+ staticMarkup(5),
84
+ ` </section>`,
85
+ `);`,
86
+ `export const MixedPanel = () => {`,
87
+ ` const [open, setOpen] = useState(false);`,
88
+ ` return (`,
89
+ ` <section>`,
90
+ staticMarkup(12),
91
+ ` <span>{open ? "open" : "shut"}</span>`,
92
+ ` </section>`,
93
+ ` );`,
94
+ `};`,
95
+ "",
96
+ ].join("\n"),
97
+ });
98
+
99
+ const result = await new AkanQualityScanner().scan(root);
100
+ const staticWarnings = rulesOf(result, "akan.ssr.client-static-component");
101
+ const mixedWarnings = rulesOf(result, "akan.ssr.client-static-markup");
102
+
103
+ expect(staticWarnings).toHaveLength(1);
104
+ expect(staticWarnings[0]?.message).toContain("StaticPanel");
105
+ expect(staticWarnings[0]?.fix).toContain("server file");
106
+ expect(mixedWarnings).toHaveLength(1);
107
+ expect(mixedWarnings[0]?.message).toContain("MixedPanel");
108
+ });
109
+
110
+ test("flags a mount-only load but not a reactive one", async () => {
111
+ const root = await makeWorkspace({
112
+ "apps/demo/lib/post/Post.Zone.tsx": [
113
+ `"use client";`,
114
+ `import { useEffect } from "react";`,
115
+ `export const List = ({ tag }: { tag: string }) => {`,
116
+ ` useEffect(() => {`,
117
+ ` void st.do.initPostInPublic();`,
118
+ ` }, []);`,
119
+ ` useEffect(() => {`,
120
+ ` void st.do.getPostListInTag(tag);`,
121
+ ` }, [tag]);`,
122
+ ` return <div>{tag}</div>;`,
123
+ `};`,
124
+ "",
125
+ ].join("\n"),
126
+ });
127
+
128
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.client-mount-load");
129
+
130
+ expect(warnings).toHaveLength(1);
131
+ expect(warnings[0]?.message).toContain("st.do.initPostInPublic");
132
+ expect(warnings[0]?.fix).toContain("init/view");
133
+ });
134
+
135
+ test("flags useState in a Template", async () => {
136
+ const root = await makeWorkspace({
137
+ "apps/demo/lib/post/Post.Template.tsx": `"use client";\nimport { useState } from "react";\nexport const General = () => {\n const [draft, setDraft] = useState("");\n return <input value={draft} onChange={(e) => setDraft(e.target.value)} />;\n};\n`,
138
+ });
139
+
140
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.template-client-state");
141
+
142
+ expect(warnings).toHaveLength(1);
143
+ expect(warnings[0]?.fix).toContain("st.do.setFieldOnX");
144
+ });
145
+
146
+ test("flags a module that renders only from client files", async () => {
147
+ const root = await makeWorkspace({
148
+ "apps/demo/lib/post/Post.Zone.tsx": [
149
+ `"use client";`,
150
+ `import { useState } from "react";`,
151
+ `export const Card = () => {`,
152
+ ` const [open] = useState(false);`,
153
+ ` return (`,
154
+ ` <section>`,
155
+ staticMarkup(14),
156
+ ` <span>{open ? "open" : "shut"}</span>`,
157
+ ` </section>`,
158
+ ` );`,
159
+ `};`,
160
+ "",
161
+ ].join("\n"),
162
+ "libs/shared/lib/user/User.Zone.tsx": `"use client";\nimport { st } from "@libs/shared/client";\nexport const Self = () => <User.View.General user={st.use.self()} />;\n`,
163
+ "libs/shared/lib/user/User.View.tsx": `export const General = ({ name }: { name: string }) => <div>{name}</div>;\n`,
164
+ });
165
+
166
+ const warnings = rulesOf(await new AkanQualityScanner().scan(root), "akan.ssr.module-missing-server-view");
167
+
168
+ expect(warnings).toHaveLength(1);
169
+ expect(warnings[0]?.message).toContain("apps/demo/lib/post");
170
+ });
171
+
172
+ test("measures the server render share per scope and for the workspace", async () => {
173
+ const root = await makeWorkspace({
174
+ "apps/demo/ui/Server.tsx": `export const Server = () => (\n <section>\n <p>a</p>\n <p>b</p>\n </section>\n);\n`,
175
+ "libs/shared/ui/Client.tsx": `"use client";\nexport const Client = () => <button onClick={() => null}>go</button>;\n`,
176
+ });
177
+
178
+ const { ssrBalance } = await new AkanQualityScanner().scan(root);
179
+
180
+ expect(ssrBalance.map((entry) => entry.scope)).toEqual(["apps/demo", "libs/shared", "workspace"]);
181
+ expect(ssrBalance[0]).toMatchObject({ serverMass: 3, clientMass: 0, serverShare: 1 });
182
+ expect(ssrBalance[1]).toMatchObject({ serverMass: 0, clientMass: 1 });
183
+ expect(ssrBalance[2]).toMatchObject({ scope: "workspace", serverMass: 3, clientMass: 1 });
184
+ });
185
+ });
package/qualityScanner.ts CHANGED
@@ -4,9 +4,10 @@ import path from "node:path";
4
4
  import ignore from "ignore";
5
5
  import ts from "typescript";
6
6
  import { AbstractDoc } from "./abstractDoc";
7
+ import { formatSsrBalance, type SsrBalanceEntry, SsrScanner } from "./ssrScanner";
7
8
 
8
9
  type QualitySeverity = "warning";
9
- type QualityScope = "global" | "file" | "convention" | "layout";
10
+ type QualityScope = "global" | "file" | "convention" | "layout" | "ssr";
10
11
 
11
12
  export interface QualityWarning {
12
13
  rule: string;
@@ -23,10 +24,11 @@ export interface QualityScanResult {
23
24
  workspaceRoot: string;
24
25
  scannedFiles: number;
25
26
  warnings: QualityWarning[];
27
+ ssrBalance: SsrBalanceEntry[];
26
28
  suggestedRules: string[];
27
29
  }
28
30
 
29
- interface SourceFileInfo {
31
+ export interface SourceFileInfo {
30
32
  file: string;
31
33
  absolutePath: string;
32
34
  content: string;
@@ -169,6 +171,18 @@ const RULE_FIXES: Record<string, string> = {
169
171
  "Move the file into a domain module folder under lib/; keep lib root limited to generated support facets.",
170
172
  "akan.layout.module-ui-file":
171
173
  "Rename the file to an allowed module UI name, or move it to ui/ if it is not a module component.",
174
+ "akan.ssr.unnecessary-use-client":
175
+ 'Delete the "use client" directive so the file renders on the server. If it exists only to wrap one client child, drop the wrapper and use the child directly.',
176
+ "akan.ssr.client-static-component":
177
+ "Move the component to a server file — a <Model>.Unit.tsx / <Model>.View.tsx for a module, or a ui/ file with no directive — and reference it from the client file.",
178
+ "akan.ssr.client-static-markup":
179
+ "Keep the interactive element in the client component and hoist the static subtree into a server component, then accept it as `children` or render it through a Unit/View reference.",
180
+ "akan.ssr.client-mount-load":
181
+ "Load the data in the route with `fetch.initX(...)` / `fetch.viewX(...)` and pass the init/view object down as a prop; the client store hydrates from it and the effect goes away.",
182
+ "akan.ssr.module-missing-server-view":
183
+ "Add a <Model>.Unit.tsx for list/card rendering and a <Model>.View.tsx for the detail surface, then have the Zone delegate to them.",
184
+ "akan.ssr.template-client-state":
185
+ "Bind the field to the store instead: `value={xForm.field}` with `onChange={st.do.setFieldOnX}`.",
172
186
  };
173
187
 
174
188
  function getRuleFix(rule: string): string | undefined {
@@ -190,6 +204,7 @@ export class AkanQualityScanner {
190
204
  .filter((file) => AbstractDoc.isAbstractPath(file))
191
205
  .map((file) => this.#readTextFile(workspaceRoot, file)),
192
206
  );
207
+ const ssr = new SsrScanner().scan(sourceFiles);
193
208
  const warnings = [
194
209
  ...this.#scanGlobalQuality(sourceFiles),
195
210
  ...sourceFiles.flatMap((sourceFile) => this.#scanSingleFileQuality(sourceFile)),
@@ -197,6 +212,7 @@ export class AkanQualityScanner {
197
212
  ...sourceFiles.flatMap((sourceFile) => this.#scanConventionQuality(sourceFile)),
198
213
  ...sourceFiles.flatMap((sourceFile) => this.#scanLayoutQuality(sourceFile)),
199
214
  ...abstractFiles.flatMap((abstractFile) => this.#scanAbstractQuality(abstractFile)),
215
+ ...ssr.warnings,
200
216
  ];
201
217
 
202
218
  return {
@@ -205,6 +221,7 @@ export class AkanQualityScanner {
205
221
  warnings: warnings
206
222
  .map((warning) => ({ ...warning, fix: warning.fix ?? getRuleFix(warning.rule) }))
207
223
  .sort(compareWarnings),
224
+ ssrBalance: ssr.balance,
208
225
  suggestedRules: SUGGESTED_RULES,
209
226
  };
210
227
  }
@@ -470,6 +487,10 @@ export function formatQualityScanResult(result: QualityScanResult) {
470
487
  "",
471
488
  ...formatQualityWarnings(result.warnings),
472
489
  "",
490
+ "SSR balance (component files, JSX elements rendered per side):",
491
+ "",
492
+ ...formatSsrBalance(result.ssrBalance),
493
+ "",
473
494
  "Suggested quality rules:",
474
495
  "",
475
496
  ...result.suggestedRules.map((rule) => ` - ${rule}`),
@@ -477,6 +498,24 @@ export function formatQualityScanResult(result: QualityScanResult) {
477
498
  return sections.join("\n");
478
499
  }
479
500
 
501
+ export function formatSsrScanResult(result: QualityScanResult) {
502
+ const sections = [
503
+ "Akan SSR Balance Scan",
504
+ `workspace: ${result.workspaceRoot}`,
505
+ `scanned files: ${result.scannedFiles}`,
506
+ `ssr warnings: ${result.warnings.length}`,
507
+ "",
508
+ "Server render share (component files, JSX elements rendered per side):",
509
+ "",
510
+ ...formatSsrBalance(result.ssrBalance),
511
+ "",
512
+ "Warnings:",
513
+ "",
514
+ ...formatQualityWarnings(result.warnings),
515
+ ];
516
+ return sections.join("\n");
517
+ }
518
+
480
519
  export function formatQualityWarnings(warnings: QualityWarning[]) {
481
520
  if (warnings.length === 0) return ["No warnings found."];
482
521
  return warnings.flatMap((warning) => {
@@ -0,0 +1,81 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import { Spinner } from "./spinner";
3
+
4
+ interface TtyStub {
5
+ restore: () => void;
6
+ rawModeCalls: boolean[];
7
+ }
8
+
9
+ /**
10
+ * ora only reaches for stdin when both streams look interactive, so the stub has to fake a tty on
11
+ * stderr (which decides `isEnabled`) as well as on stdin.
12
+ */
13
+ const stubTty = (): TtyStub => {
14
+ const rawModeCalls: boolean[] = [];
15
+ const stdin = process.stdin as NodeJS.ReadStream & { setRawMode?: (mode: boolean) => unknown };
16
+ const previous = {
17
+ stdinIsTTY: stdin.isTTY,
18
+ stderrIsTTY: process.stderr.isTTY,
19
+ stderrColumns: process.stderr.columns,
20
+ setRawMode: stdin.setRawMode,
21
+ isPaused: stdin.isPaused,
22
+ };
23
+ stdin.isTTY = true;
24
+ process.stderr.isTTY = true;
25
+ process.stderr.columns = 120;
26
+ stdin.setRawMode = (mode: boolean) => {
27
+ rawModeCalls.push(mode);
28
+ return stdin;
29
+ };
30
+ stdin.isPaused = () => true;
31
+ return {
32
+ rawModeCalls,
33
+ restore: () => {
34
+ stdin.isTTY = previous.stdinIsTTY;
35
+ process.stderr.isTTY = previous.stderrIsTTY;
36
+ process.stderr.columns = previous.stderrColumns;
37
+ stdin.setRawMode = previous.setRawMode;
38
+ stdin.isPaused = previous.isPaused;
39
+ },
40
+ };
41
+ };
42
+
43
+ describe("Spinner", () => {
44
+ // A raw terminal at spawn time is what a Bun child snapshots and writes back when it exits, which is
45
+ // how `akan start` used to leave a terminal that no longer turns Ctrl+C into SIGINT.
46
+ test("never puts the terminal into raw mode while it spins", () => {
47
+ const tty = stubTty();
48
+ try {
49
+ const spinner = new Spinner("Preparing backend...").start();
50
+ expect(tty.rawModeCalls).toEqual([]);
51
+ spinner.succeed("prepared");
52
+ expect(tty.rawModeCalls).toEqual([]);
53
+ } finally {
54
+ tty.restore();
55
+ }
56
+ });
57
+
58
+ test("keeps ora's stdin discarder disabled", () => {
59
+ expect(Spinner.oraOptions.discardStdin).toBe(false);
60
+ });
61
+
62
+ // An unsized pty reports `isTTY: true` with `columns: 0`, which turns ora's clear loop into an
63
+ // infinite one — the process then writes cursor moves until it is SIGKILLed.
64
+ test("refuses to animate against a tty that reports no width", () => {
65
+ expect(Spinner.canAnimate({ isTTY: true, columns: 0 } as NodeJS.WriteStream)).toBe(false);
66
+ expect(Spinner.canAnimate({ isTTY: true, columns: 120 } as NodeJS.WriteStream)).toBe(true);
67
+ expect(Spinner.canAnimate({ isTTY: false, columns: 0 } as NodeJS.WriteStream)).toBe(true);
68
+ });
69
+
70
+ test("falls back to plain lines when the terminal has no width", () => {
71
+ const previous = { isTTY: process.stderr.isTTY, columns: process.stderr.columns };
72
+ process.stderr.isTTY = true;
73
+ process.stderr.columns = 0;
74
+ try {
75
+ expect(new Spinner("Preparing backend...").enableSpin).toBe(false);
76
+ } finally {
77
+ process.stderr.isTTY = previous.isTTY;
78
+ process.stderr.columns = previous.columns;
79
+ }
80
+ });
81
+ });
package/spinner.ts CHANGED
@@ -2,6 +2,26 @@ import ora, { type Ora } from "ora";
2
2
 
3
3
  export class Spinner {
4
4
  static padding = 12;
5
+ /**
6
+ * XXX: `discardStdin` must stay off. It makes ora put the terminal into raw mode for as long as the
7
+ * spinner runs, and a Bun child spawned in that window snapshots the raw termios and writes it back
8
+ * when it exits — long after the spinner restored the terminal. `akan start` spawns the builder and
9
+ * the backend under the "Preparing backend..." spinner, so the first builder recycle (a config or
10
+ * runtime-metadata change) SIGTERMs that child and silently turns the developer's terminal raw:
11
+ * `isig` goes off, Ctrl+C stops producing SIGINT at all, and the dev server looks unkillable.
12
+ */
13
+ static oraOptions = { discardStdin: false } as const;
14
+ /**
15
+ * ora sizes its clear loop as `ceil(lineWidth / stream.columns)`, so a tty that reports **0** columns
16
+ * makes it `Infinity` and `clear()` never returns. An unsized pty does exactly that (`isTTY: true`,
17
+ * `columns: 0`) — CI runners, `expect`/`script` harnesses, some detached panes. Measured: 750MB of
18
+ * cursor moves and 8.7GB RSS inside a minute, in a loop that no longer reaches the point where SIGINT
19
+ * or SIGTERM could be handled, so only SIGKILL ends it. A terminal with no width has nothing to
20
+ * animate anyway; fall back to plain lines.
21
+ */
22
+ static canAnimate(stream: NodeJS.WriteStream = process.stderr): boolean {
23
+ return !stream.isTTY || stream.columns > 0;
24
+ }
5
25
  spinner: Ora;
6
26
  stopWatch: NodeJS.Timeout | null = null;
7
27
  startAt: Date = new Date();
@@ -12,10 +32,10 @@ export class Spinner {
12
32
  Spinner.padding = Math.max(Spinner.padding, prefix.length);
13
33
  this.prefix = prefix;
14
34
  this.message = message;
15
- this.spinner = ora(message);
35
+ this.spinner = ora({ ...Spinner.oraOptions, text: message });
16
36
  this.spinner.prefixText = prefix.padStart(Spinner.padding, " ");
17
37
  this.spinner.indent = indent;
18
- this.enableSpin = enableSpin;
38
+ this.enableSpin = enableSpin && Spinner.canAnimate();
19
39
  }
20
40
  start() {
21
41
  this.startAt = new Date();
package/ssrScanner.ts ADDED
@@ -0,0 +1,409 @@
1
+ import ts from "typescript";
2
+ import type { QualityWarning, SourceFileInfo } from "./qualityScanner";
3
+
4
+ /** Server/client render split for one app or lib, measured in JSX elements rather than files. */
5
+ export interface SsrBalanceEntry {
6
+ scope: string;
7
+ serverMass: number;
8
+ clientMass: number;
9
+ serverShare: number;
10
+ }
11
+
12
+ export interface SsrScanResult {
13
+ warnings: QualityWarning[];
14
+ balance: SsrBalanceEntry[];
15
+ }
16
+
17
+ interface ComponentInfo {
18
+ name: string;
19
+ line: number;
20
+ mass: number;
21
+ touches: string[];
22
+ vendorTags: boolean;
23
+ }
24
+
25
+ // A component with no client-only touch at all never needed the client bundle, so even a small subtree is
26
+ // worth moving. A mostly-static component keeps its interaction and hands the static part to the server, so
27
+ // it only pays off once the static subtree is large enough to matter.
28
+ const STATIC_COMPONENT_MIN_MASS = 4;
29
+ const MIXED_COMPONENT_MIN_MASS = 10;
30
+ const MIXED_COMPONENT_MAX_TOUCHES = 2;
31
+ const MODULE_SERVER_VIEW_MIN_CLIENT_MASS = 12;
32
+
33
+ export class SsrScanner {
34
+ // `usePage` and `getSelf` read request-scoped server context and are legal in server components, so they
35
+ // must not count as evidence that a file needs "use client".
36
+ static #serverSafeCalls = new Set(["usePage", "getSelf", "useServer"]);
37
+ static #clientGlobals = new Set([
38
+ "window",
39
+ "document",
40
+ "navigator",
41
+ "localStorage",
42
+ "sessionStorage",
43
+ "location",
44
+ "history",
45
+ "screen",
46
+ "matchMedia",
47
+ "IntersectionObserver",
48
+ "ResizeObserver",
49
+ "MutationObserver",
50
+ "requestAnimationFrame",
51
+ "WebSocket",
52
+ ]);
53
+ // Runtime singletons that only exist in the client bundle; importing either is what forces the directive.
54
+ static #clientRuntimeImports = new Set(["st", "fetch"]);
55
+
56
+ scan(sourceFiles: SourceFileInfo[]): SsrScanResult {
57
+ const componentFiles = sourceFiles.filter((sourceFile) => this.#isBalancedFile(sourceFile.file));
58
+ return {
59
+ warnings: [
60
+ ...componentFiles.flatMap((sourceFile) => this.#scanFile(sourceFile)),
61
+ ...this.#scanModules(componentFiles),
62
+ ],
63
+ balance: this.#measureBalance(componentFiles),
64
+ };
65
+ }
66
+
67
+ #scanFile(sourceFile: SourceFileInfo): QualityWarning[] {
68
+ if (!this.#hasUseClient(sourceFile.sourceFile)) return [];
69
+ const vendorNames = this.#getVendorNames(sourceFile.sourceFile);
70
+ const hasVendorImport = this.#hasVendorImport(sourceFile.sourceFile);
71
+ const importsClientRuntime = this.#importsClientRuntime(sourceFile.sourceFile);
72
+ const components = this.#getComponents(sourceFile, vendorNames);
73
+ const warnings: QualityWarning[] = [];
74
+
75
+ if (
76
+ !hasVendorImport &&
77
+ !importsClientRuntime &&
78
+ this.#getTouches(sourceFile.sourceFile, sourceFile.sourceFile).length === 0 &&
79
+ !this.#isConventionClientFile(sourceFile.file)
80
+ ) {
81
+ warnings.push({
82
+ rule: "akan.ssr.unnecessary-use-client",
83
+ scope: "ssr",
84
+ severity: "warning",
85
+ file: sourceFile.file,
86
+ line: 1,
87
+ message: `"use client" is declared but the file uses no client-only capability (hook, event handler, store, or browser API).`,
88
+ });
89
+ }
90
+
91
+ for (const component of components) {
92
+ if (component.vendorTags) continue;
93
+ if (component.touches.length === 0 && component.mass >= STATIC_COMPONENT_MIN_MASS) {
94
+ warnings.push({
95
+ rule: "akan.ssr.client-static-component",
96
+ scope: "ssr",
97
+ severity: "warning",
98
+ file: sourceFile.file,
99
+ line: component.line,
100
+ message: `Client component "${component.name}" renders ${component.mass} JSX elements with no client-only capability. It is server-renderable markup sitting in the client bundle.`,
101
+ });
102
+ continue;
103
+ }
104
+ if (
105
+ component.touches.length >= 1 &&
106
+ component.touches.length <= MIXED_COMPONENT_MAX_TOUCHES &&
107
+ component.mass >= MIXED_COMPONENT_MIN_MASS
108
+ ) {
109
+ warnings.push({
110
+ rule: "akan.ssr.client-static-markup",
111
+ scope: "ssr",
112
+ severity: "warning",
113
+ file: sourceFile.file,
114
+ line: component.line,
115
+ message: `Client component "${component.name}" renders ${component.mass} JSX elements around only ${component.touches.length} client-only touch (${[...new Set(component.touches)].join(", ")}). Most of this subtree does not need the client bundle.`,
116
+ });
117
+ }
118
+ }
119
+
120
+ warnings.push(...this.#getMountLoadWarnings(sourceFile));
121
+ warnings.push(...this.#getTemplateStateWarnings(sourceFile));
122
+ return warnings;
123
+ }
124
+
125
+ // A database module whose rendering happens entirely in Template/Zone/Util has no server-rendered surface at
126
+ // all, so every consumer pays for hydration even when it only needs to display the model.
127
+ #scanModules(sourceFiles: SourceFileInfo[]): QualityWarning[] {
128
+ const modules = new Map<string, { clientMass: number; serverFiles: number; line: string }>();
129
+ for (const sourceFile of sourceFiles) {
130
+ const moduleDir = this.#getModuleDir(sourceFile.file);
131
+ if (!moduleDir) continue;
132
+ const entry = modules.get(moduleDir) ?? { clientMass: 0, serverFiles: 0, line: sourceFile.file };
133
+ if (this.#hasUseClient(sourceFile.sourceFile)) entry.clientMass += this.#getMass(sourceFile.sourceFile);
134
+ else if (/\.(Unit|View)\.tsx$/.test(sourceFile.file)) entry.serverFiles += 1;
135
+ modules.set(moduleDir, entry);
136
+ }
137
+ return [...modules]
138
+ .filter(([, entry]) => entry.serverFiles === 0 && entry.clientMass >= MODULE_SERVER_VIEW_MIN_CLIENT_MASS)
139
+ .map(([moduleDir, entry]) => ({
140
+ rule: "akan.ssr.module-missing-server-view",
141
+ scope: "ssr" as const,
142
+ severity: "warning" as const,
143
+ file: entry.line,
144
+ message: `Module "${moduleDir}" renders ${entry.clientMass} JSX elements from client files only; it declares no Unit or View server component.`,
145
+ }));
146
+ }
147
+
148
+ // A load fired from a mount-only effect is data the route already could have fetched: the client renders an
149
+ // empty shell, hydrates, then fetches. A reactive effect (non-empty deps) responds to client state instead
150
+ // and has no server-side equivalent, so only the empty-dependency form is a finding.
151
+ #getMountLoadWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
152
+ const warnings: QualityWarning[] = [];
153
+ const visit = (node: ts.Node) => {
154
+ if (this.#isMountEffect(sourceFile.sourceFile, node)) {
155
+ for (const load of this.#getLoadCalls(sourceFile.sourceFile, node)) {
156
+ warnings.push({
157
+ rule: "akan.ssr.client-mount-load",
158
+ scope: "ssr",
159
+ severity: "warning",
160
+ file: sourceFile.file,
161
+ line: this.#getLine(sourceFile.sourceFile, load.node),
162
+ message: `Mount-only effect loads server data with ${load.callee}(). The route can fetch this before the first byte instead.`,
163
+ });
164
+ }
165
+ }
166
+ ts.forEachChild(node, visit);
167
+ };
168
+ ts.forEachChild(sourceFile.sourceFile, visit);
169
+ return warnings;
170
+ }
171
+
172
+ #isMountEffect(sourceFile: ts.SourceFile, node: ts.Node) {
173
+ if (!ts.isCallExpression(node)) return false;
174
+ const callee = node.expression.getText(sourceFile);
175
+ if (callee !== "useEffect" && callee !== "useLayoutEffect") return false;
176
+ const deps = node.arguments[1];
177
+ return !!deps && ts.isArrayLiteralExpression(deps) && deps.elements.length === 0;
178
+ }
179
+
180
+ #getLoadCalls(sourceFile: ts.SourceFile, node: ts.Node) {
181
+ const calls: Array<{ callee: string; node: ts.Node }> = [];
182
+ const visit = (child: ts.Node) => {
183
+ if (ts.isCallExpression(child)) {
184
+ const callee = child.expression.getText(sourceFile);
185
+ if (/^fetch\.[a-z]/.test(callee) || /^st\.do\.(init|get|view|load|list|count|insight)[A-Z]/.test(callee))
186
+ calls.push({ callee, node: child });
187
+ }
188
+ ts.forEachChild(child, visit);
189
+ };
190
+ ts.forEachChild(node, visit);
191
+ return calls;
192
+ }
193
+
194
+ #getTemplateStateWarnings(sourceFile: SourceFileInfo): QualityWarning[] {
195
+ if (!sourceFile.file.endsWith(".Template.tsx")) return [];
196
+ const warnings: QualityWarning[] = [];
197
+ const visit = (node: ts.Node) => {
198
+ if (ts.isCallExpression(node) && node.expression.getText(sourceFile.sourceFile) === "useState") {
199
+ warnings.push({
200
+ rule: "akan.ssr.template-client-state",
201
+ scope: "ssr",
202
+ severity: "warning",
203
+ file: sourceFile.file,
204
+ line: this.#getLine(sourceFile.sourceFile, node),
205
+ message: "Template holds form state in useState. Templates are store-driven and carry no local state.",
206
+ });
207
+ }
208
+ ts.forEachChild(node, visit);
209
+ };
210
+ ts.forEachChild(sourceFile.sourceFile, visit);
211
+ return warnings;
212
+ }
213
+
214
+ #measureBalance(sourceFiles: SourceFileInfo[]): SsrBalanceEntry[] {
215
+ const scopes = new Map<string, { serverMass: number; clientMass: number }>();
216
+ for (const sourceFile of sourceFiles) {
217
+ const segments = sourceFile.file.split("/");
218
+ const scope = `${segments[0]}/${segments[1]}`;
219
+ const entry = scopes.get(scope) ?? { serverMass: 0, clientMass: 0 };
220
+ const mass = this.#getMass(sourceFile.sourceFile);
221
+ if (this.#hasUseClient(sourceFile.sourceFile)) entry.clientMass += mass;
222
+ else entry.serverMass += mass;
223
+ scopes.set(scope, entry);
224
+ }
225
+ const entries = [...scopes]
226
+ .map(([scope, mass]) => ({ scope, ...mass, serverShare: getShare(mass.serverMass, mass.clientMass) }))
227
+ .sort((a, b) => a.scope.localeCompare(b.scope));
228
+ if (entries.length < 2) return entries;
229
+ const serverMass = entries.reduce((sum, entry) => sum + entry.serverMass, 0);
230
+ const clientMass = entries.reduce((sum, entry) => sum + entry.clientMass, 0);
231
+ return [...entries, { scope: "workspace", serverMass, clientMass, serverShare: getShare(serverMass, clientMass) }];
232
+ }
233
+
234
+ #getComponents(sourceFile: SourceFileInfo, vendorNames: Set<string>): ComponentInfo[] {
235
+ const components: ComponentInfo[] = [];
236
+ for (const statement of sourceFile.sourceFile.statements) {
237
+ for (const { name, node } of this.#getComponentNodes(statement)) {
238
+ if (!/^[A-Z]/.test(name)) continue;
239
+ components.push({
240
+ name,
241
+ line: this.#getLine(sourceFile.sourceFile, node),
242
+ mass: this.#getMass(node),
243
+ touches: this.#getTouches(sourceFile.sourceFile, node),
244
+ vendorTags: [...this.#getTagNames(sourceFile.sourceFile, node)].some((tag) => vendorNames.has(tag)),
245
+ });
246
+ }
247
+ }
248
+ return components;
249
+ }
250
+
251
+ #getComponentNodes(statement: ts.Statement): Array<{ name: string; node: ts.Node }> {
252
+ if (ts.isFunctionDeclaration(statement) && statement.body)
253
+ return [{ name: statement.name?.text ?? "default", node: statement.body }];
254
+ if (!ts.isVariableStatement(statement)) return [];
255
+ return statement.declarationList.declarations.flatMap((declaration) => {
256
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) return [];
257
+ if (!ts.isArrowFunction(declaration.initializer) && !ts.isFunctionExpression(declaration.initializer)) return [];
258
+ return [{ name: declaration.name.text, node: declaration.initializer }];
259
+ });
260
+ }
261
+
262
+ #getTouches(sourceFile: ts.SourceFile, node: ts.Node): string[] {
263
+ const touches: string[] = [];
264
+ const visit = (child: ts.Node) => {
265
+ if (ts.isCallExpression(child)) {
266
+ const callee = child.expression.getText(sourceFile);
267
+ const bareName = callee.split(".").pop() ?? callee;
268
+ if (callee === "createContext" || callee === "lazy") touches.push(callee);
269
+ else if (/^use[A-Z]/.test(bareName) && !SsrScanner.#serverSafeCalls.has(bareName)) touches.push(bareName);
270
+ }
271
+ if (ts.isJsxAttribute(child) && /^on[A-Z]/.test(child.name.getText(sourceFile)))
272
+ touches.push(child.name.getText(sourceFile));
273
+ if (ts.isPropertyAccessExpression(child)) {
274
+ const root = getAccessRoot(child);
275
+ if (root === "st") touches.push("st");
276
+ else if (SsrScanner.#clientGlobals.has(root)) touches.push(root);
277
+ }
278
+ ts.forEachChild(child, visit);
279
+ };
280
+ ts.forEachChild(node, visit);
281
+ return touches;
282
+ }
283
+
284
+ #getTagNames(sourceFile: ts.SourceFile, node: ts.Node): Set<string> {
285
+ const tags = new Set<string>();
286
+ const visit = (child: ts.Node) => {
287
+ if (ts.isJsxOpeningElement(child) || ts.isJsxSelfClosingElement(child))
288
+ tags.add(child.tagName.getText(sourceFile).split(".")[0]);
289
+ ts.forEachChild(child, visit);
290
+ };
291
+ ts.forEachChild(node, visit);
292
+ return tags;
293
+ }
294
+
295
+ #getMass(node: ts.Node) {
296
+ let mass = 0;
297
+ const visit = (child: ts.Node) => {
298
+ if (ts.isJsxOpeningElement(child) || ts.isJsxSelfClosingElement(child)) mass += 1;
299
+ ts.forEachChild(child, visit);
300
+ };
301
+ ts.forEachChild(node, visit);
302
+ return mass;
303
+ }
304
+
305
+ #hasUseClient(sourceFile: ts.SourceFile) {
306
+ const first = sourceFile.statements[0];
307
+ if (!first || !ts.isExpressionStatement(first) || !ts.isStringLiteral(first.expression)) return false;
308
+ return first.expression.text === "use client";
309
+ }
310
+
311
+ // A bare specifier is a third-party package: it may be client-only, which is a legitimate reason for the
312
+ // directive that no amount of AST reading can rule out.
313
+ #hasVendorImport(sourceFile: ts.SourceFile) {
314
+ return sourceFile.statements.some(
315
+ (statement) => ts.isImportDeclaration(statement) && isVendorSpecifier(getSpecifier(statement)),
316
+ );
317
+ }
318
+
319
+ #getVendorNames(sourceFile: ts.SourceFile) {
320
+ const names = new Set<string>();
321
+ for (const statement of sourceFile.statements) {
322
+ if (!ts.isImportDeclaration(statement) || !isVendorSpecifier(getSpecifier(statement))) continue;
323
+ const clause = statement.importClause;
324
+ if (clause?.name) names.add(clause.name.text);
325
+ if (clause?.namedBindings && ts.isNamespaceImport(clause.namedBindings))
326
+ names.add(clause.namedBindings.name.text);
327
+ if (clause?.namedBindings && ts.isNamedImports(clause.namedBindings))
328
+ for (const element of clause.namedBindings.elements) names.add(element.name.text);
329
+ }
330
+ return names;
331
+ }
332
+
333
+ #importsClientRuntime(sourceFile: ts.SourceFile) {
334
+ for (const statement of sourceFile.statements) {
335
+ if (!ts.isImportDeclaration(statement)) continue;
336
+ const bindings = statement.importClause?.namedBindings;
337
+ if (!bindings || !ts.isNamedImports(bindings)) continue;
338
+ if (bindings.elements.some((element) => SsrScanner.#clientRuntimeImports.has(element.name.text))) return true;
339
+ }
340
+ return false;
341
+ }
342
+
343
+ // Zone/Template/Util carry the directive mechanically by file role, and `index_.tsx` is the declared
344
+ // "use client" + lazy() boundary. In neither case is the directive a stray — for module UI it means markup
345
+ // belongs in a Unit or View instead, which the component rules already cover.
346
+ #isConventionClientFile(file: string) {
347
+ if (file.endsWith("/index_.tsx")) return true;
348
+ return /\.(Zone|Template|Util)\.tsx$/.test(file) && this.#getModuleDir(file) !== null;
349
+ }
350
+
351
+ #isBalancedFile(file: string) {
352
+ if (!file.endsWith(".tsx") || file.endsWith(".test.tsx") || file.endsWith(".spec.tsx")) return false;
353
+ const segments = file.split("/");
354
+ if (segments[0] !== "apps" && segments[0] !== "libs") return false;
355
+ return segments[2] === "ui" || segments[2] === "lib";
356
+ }
357
+
358
+ #getModuleDir(file: string) {
359
+ const segments = file.split("/");
360
+ const libIndex = segments.indexOf("lib");
361
+ if (libIndex < 1 || segments.length <= libIndex + 2) return null;
362
+ const moduleName = segments[libIndex + 1];
363
+ if (moduleName.startsWith("_")) return null;
364
+ return segments.slice(0, libIndex + 2).join("/");
365
+ }
366
+
367
+ #getLine(sourceFile: ts.SourceFile, node: ts.Node) {
368
+ return sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1;
369
+ }
370
+ }
371
+
372
+ /** Share of component rendering an app or lib should keep on the server before the split needs a reason. */
373
+ export const SSR_SERVER_SHARE_TARGET = 0.5;
374
+
375
+ export function formatSsrBalance(balance: SsrBalanceEntry[]) {
376
+ if (balance.length === 0) return ["No component files found."];
377
+ return balance.map((entry) => {
378
+ const total = entry.serverMass + entry.clientMass;
379
+ const share = `${Math.round(entry.serverShare * 100)}% server`;
380
+ const counts = `${entry.serverMass} of ${total} JSX elements, ${entry.clientMass} client`;
381
+ const flag =
382
+ entry.serverShare < SSR_SERVER_SHARE_TARGET
383
+ ? ` <- below the ${Math.round(SSR_SERVER_SHARE_TARGET * 100)}% target`
384
+ : "";
385
+ return ` ${entry.scope}: ${share} (${counts})${flag}`;
386
+ });
387
+ }
388
+
389
+ function getShare(serverMass: number, clientMass: number) {
390
+ const total = serverMass + clientMass;
391
+ return total === 0 ? 1 : serverMass / total;
392
+ }
393
+
394
+ function getSpecifier(statement: ts.ImportDeclaration) {
395
+ return ts.isStringLiteral(statement.moduleSpecifier) ? statement.moduleSpecifier.text : "";
396
+ }
397
+
398
+ function isVendorSpecifier(specifier: string) {
399
+ if (specifier === "" || specifier.startsWith(".") || specifier.startsWith("/")) return false;
400
+ if (specifier === "react" || specifier === "react-dom" || specifier.startsWith("react/")) return false;
401
+ if (specifier.startsWith("node:")) return false;
402
+ return !/^(akanjs|@akanjs|@libs|@apps|@contract)(\/|$)/.test(specifier);
403
+ }
404
+
405
+ function getAccessRoot(node: ts.PropertyAccessExpression) {
406
+ let current: ts.Expression = node;
407
+ while (ts.isPropertyAccessExpression(current)) current = current.expression;
408
+ return ts.isIdentifier(current) ? current.text : "";
409
+ }