@akanjs/devkit 2.4.2-rc.3 → 3.0.0-alpha.1
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/agentsIndex.test.ts +84 -0
- package/agentsIndex.ts +146 -0
- package/akanContext.ts +122 -0
- package/executors.ts +26 -0
- package/frontendBuild/frontendBuild.test.ts +3 -1
- package/frontendBuild/index.ts +3 -0
- package/frontendBuild/ssrBaseArtifactBuilder.ts +2 -0
- package/frontendBuild/styleContract.ts +29 -0
- package/frontendBuild/styleGuard.test.ts +165 -0
- package/frontendBuild/styleGuard.ts +322 -0
- package/frontendBuild/themeValidator.test.ts +70 -0
- package/frontendBuild/themeValidator.ts +150 -0
- package/index.ts +1 -0
- package/integration/devResourceProbe.ts +7 -2
- package/integration/ssrMemoryProbe.ts +542 -0
- package/lint/no-arbitrary-color.grit +19 -0
- package/lint/no-daisyui-legacy-class.grit +20 -0
- package/lint/no-inline-color.grit +19 -0
- package/lint/no-interpolated-arbitrary-class.grit +24 -0
- package/lint/no-raw-palette-class.grit +25 -0
- package/package.json +2 -3
- package/qualityScanner.test.ts +140 -1
- package/qualityScanner.ts +41 -2
- package/recipeScanner.test.ts +183 -0
- package/recipeScanner.ts +233 -0
- package/scanInfo.ts +3 -0
- package/spinner.test.ts +81 -0
- package/spinner.ts +22 -2
- package/ssrScanner.ts +409 -0
- package/transforms/externalizeFrameworkPlugin.ts +2 -2
|
@@ -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,19 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Arbitrary-value color literals (`bg-[#3b82f6]`, `text-[rgb(0,0,0)]`) bypass the semantic
|
|
5
|
+
// token layer, so they ignore data-theme switching and theme contrast validation. Grit port
|
|
6
|
+
// of styleGuard's `arbitrary-color` rule (frontendBuild/styleGuard.ts) — keep in sync.
|
|
7
|
+
// Variable references (`bg-[--brand]`, `text-[var(--fg)]`) do not match, by design.
|
|
8
|
+
or {
|
|
9
|
+
JsxString() as $s,
|
|
10
|
+
JsStringLiteralExpression() as $s,
|
|
11
|
+
JsTemplateChunkElement() as $s
|
|
12
|
+
} where {
|
|
13
|
+
$s <: r"[\s\S]*\[(?:#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb|color)\([^\]]*\))\][\s\S]*",
|
|
14
|
+
register_diagnostic(
|
|
15
|
+
span = $s,
|
|
16
|
+
message = "Arbitrary color value bypasses the semantic token layer (no theme switching, no contrast validation). Use a semantic token, or reference a variable (bg-[--brand]) declared in styles.css.",
|
|
17
|
+
severity = "error"
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// daisyUI was removed from the UI system; its component classes (`btn-primary`, `card-body`,
|
|
5
|
+
// `mockup-code`, ...) no longer have any CSS behind them and silently render unstyled. Grit
|
|
6
|
+
// port of styleGuard's `daisyui-legacy` rule (frontendBuild/styleGuard.ts) — keep in sync.
|
|
7
|
+
// Only high-signal compound names are matched; bare ambiguous words that collide with
|
|
8
|
+
// Tailwind or app classes (`card`, `input`, `badge`, `btn`) are deliberately not flagged.
|
|
9
|
+
or {
|
|
10
|
+
JsxString() as $s,
|
|
11
|
+
JsStringLiteralExpression() as $s,
|
|
12
|
+
JsTemplateChunkElement() as $s
|
|
13
|
+
} where {
|
|
14
|
+
$s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:btn-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|link|outline|square|circle|wide|block|xs|sm|md|lg)|badge-(?:primary|secondary|accent|neutral|info|success|warning|error|ghost|outline)|alert-(?:info|success|warning|error)|input-(?:bordered|primary|secondary|accent|ghost|error)|select-(?:bordered|primary|ghost)|textarea-(?:bordered|primary|ghost)|checkbox-(?:primary|secondary|accent)|toggle-(?:primary|secondary|accent)|loading-(?:spinner|dots|ring|ball|bars|infinity)|card-(?:body|title|actions)|modal-(?:box|action|backdrop)|collapse-(?:title|content|arrow|plus)|dropdown-(?:content|end|start|hover)|stat-(?:title|value|desc)|tabs-(?:boxed|lifted|bordered)|tab-active|menu-(?:title|dropdown)|steps-(?:horizontal|vertical)|join-item|mockup-(?:code|phone|browser|window)|drawer-(?:side|content|toggle))(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
|
|
15
|
+
register_diagnostic(
|
|
16
|
+
span = $s,
|
|
17
|
+
message = "daisyUI legacy class - daisyUI was removed, so this renders unstyled. Use akanjs/ui components and recipes (Button, buttonRecipe, badgeRecipe, ...) with semantic tokens instead.",
|
|
18
|
+
severity = "error"
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Color literals inside `style={{ ... }}` bypass the class scanner, the semantic token layer,
|
|
5
|
+
// and theme switching entirely. Grit port of styleGuard's `inline-color` rule
|
|
6
|
+
// (frontendBuild/styleGuard.ts) — keep in sync. `var(--token)` references do not match.
|
|
7
|
+
//
|
|
8
|
+
// Known delta vs the TS guard: color literals inside a `<style>` tag's template body are not
|
|
9
|
+
// matched here (template chunks are not scoped by this pattern); the build-gate styleGuard
|
|
10
|
+
// still covers that case.
|
|
11
|
+
JsStringLiteralExpression() as $s where {
|
|
12
|
+
$s <: within `style={$obj}`,
|
|
13
|
+
$s <: r"[\s\S]*(?:#[0-9a-fA-F]{3,8}|(?:rgb|rgba|hsl|hsla|oklch|oklab|lab|lch|hwb)\()[\s\S]*",
|
|
14
|
+
register_diagnostic(
|
|
15
|
+
span = $s,
|
|
16
|
+
message = "Inline color literal in a style object bypasses semantic tokens and theme switching. Use a token class, or style={{ color: 'var(--primary)' }} when a runtime value is unavoidable.",
|
|
17
|
+
severity = "error"
|
|
18
|
+
)
|
|
19
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Tailwind extracts arbitrary values from raw source *text*, so a bracket whose contents are built at
|
|
5
|
+
// runtime (`min-h-[${minHeight}px]`, `bg-[${color}]`) compiles to NO css. The class name exists in the DOM
|
|
6
|
+
// and the prop looks wired, but nothing applies — the failure is completely silent, and it gets worse when a
|
|
7
|
+
// literal of the same shape happens to exist elsewhere in the codebase: the default value works by accident
|
|
8
|
+
// while every override dies quietly (that is exactly how `Empty`'s minHeight went unnoticed).
|
|
9
|
+
//
|
|
10
|
+
// Grit port of styleGuard's `interpolated-arbitrary` rule (frontendBuild/styleGuard.ts) — keep the two
|
|
11
|
+
// regexes in sync. Matching template *chunks* is what makes this detectable: the chunk before `${` still
|
|
12
|
+
// carries the unterminated `-[`.
|
|
13
|
+
//
|
|
14
|
+
// The fix is a style prop for the runtime value (`style={{ minHeight }}`) — dimensions are not colors, so
|
|
15
|
+
// no-inline-color does not apply — or a fixed set of literal classes when the value comes from an enum.
|
|
16
|
+
// Escape hatch: `// biome-ignore lint/plugin: <reason>`.
|
|
17
|
+
JsTemplateChunkElement() as $chunk where {
|
|
18
|
+
$chunk <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])[a-z][a-z0-9-]*-\[[^\]]*$",
|
|
19
|
+
register_diagnostic(
|
|
20
|
+
span = $chunk,
|
|
21
|
+
message = "Interpolated Tailwind arbitrary value (e.g. `min-h-[${n}px]`) - the scanner reads source text, so this compiles to no CSS and is silently ignored. Use a style prop for the runtime value, or a fixed set of literal classes.",
|
|
22
|
+
severity = "error"
|
|
23
|
+
)
|
|
24
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
engine biome(1.0)
|
|
2
|
+
language js(typescript, jsx)
|
|
3
|
+
|
|
4
|
+
// Vocabulary closure (`@theme { --color-*: initial }` in akanjs/ui/styles.css) strips the raw
|
|
5
|
+
// Tailwind palette, so classes like `bg-blue-500` generate NO css and silently render as
|
|
6
|
+
// currentColor/transparent. This rule surfaces that silent breakage at lint time. It is the
|
|
7
|
+
// grit port of styleGuard's `raw-palette` rule (frontendBuild/styleGuard.ts) — keep the two
|
|
8
|
+
// regexes in sync. Matching string/template nodes (not raw text) skips comments for free.
|
|
9
|
+
//
|
|
10
|
+
// Bare `bg-neutral` is a semantic token and allowed; the numeric suffix (`bg-neutral-500`)
|
|
11
|
+
// is what marks a raw palette class. black/white stay in the vocabulary for overlays.
|
|
12
|
+
// Escape hatch for legitimate fixed colors: `// biome-ignore lint/plugin: <reason>`
|
|
13
|
+
// (file-wide: `// biome-ignore-all lint/plugin: <reason>`).
|
|
14
|
+
or {
|
|
15
|
+
JsxString() as $s,
|
|
16
|
+
JsStringLiteralExpression() as $s,
|
|
17
|
+
JsTemplateChunkElement() as $s
|
|
18
|
+
} where {
|
|
19
|
+
$s <: r"[\s\S]*(?:^|[\s\"'`{(\[:!])(?:bg|text|border(?:-[tblrxy])?(?:-[se])?|ring(?:-offset)?|fill|stroke|shadow|from|to|via|divide|outline|decoration|placeholder|caret|accent)-(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}(?:[\s\"'`})\]:/!,]|$)[\s\S]*",
|
|
20
|
+
register_diagnostic(
|
|
21
|
+
span = $s,
|
|
22
|
+
message = "Raw Tailwind palette class (e.g. bg-blue-500) - vocabulary closure strips these, so they render as no CSS. Use a semantic token (bg-primary, text-foreground/70, ...) or add a biome-ignore with a reason.",
|
|
23
|
+
severity = "error"
|
|
24
|
+
)
|
|
25
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@akanjs/devkit",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0-alpha.1",
|
|
4
4
|
"sourceType": "module",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"publishConfig": {
|
|
@@ -44,10 +44,9 @@
|
|
|
44
44
|
"@langchain/openai": "^1.4.6",
|
|
45
45
|
"@tailwindcss/node": "^4.3.0",
|
|
46
46
|
"@trapezedev/project": "^7.1.4",
|
|
47
|
-
"akanjs": "
|
|
47
|
+
"akanjs": "3.0.0-alpha.1",
|
|
48
48
|
"chalk": "^5.6.2",
|
|
49
49
|
"commander": "^14.0.3",
|
|
50
|
-
"daisyui": "5.5.23",
|
|
51
50
|
"dayjs": "^1.11.20",
|
|
52
51
|
"fontaine": "^0.8.0",
|
|
53
52
|
"fonteditor-core": "^2.6.3",
|