@forinda/kickjs-devtools 3.1.3 → 4.0.0

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/dist/index.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @forinda/kickjs-devtools v3.1.3
2
+ * @forinda/kickjs-devtools v4.0.0
3
3
  *
4
4
  * Copyright (c) Felix Orinda
5
5
  *
@@ -11,10 +11,238 @@
11
11
  import "reflect-metadata";
12
12
  import { Router, static as static$1 } from "express";
13
13
  import { dirname, join } from "node:path";
14
+ import { tmpdir } from "node:os";
14
15
  import { fileURLToPath } from "node:url";
15
- import { existsSync, readFileSync } from "node:fs";
16
+ import { createReadStream, existsSync, readFileSync, statSync, unlink } from "node:fs";
16
17
  import { randomBytes } from "node:crypto";
17
- import { METADATA, computed, createLogger, getClassMeta, getMethodMeta, reactive, ref, watch } from "@forinda/kickjs";
18
+ import { writeHeapSnapshot } from "node:v8";
19
+ import { METADATA, computed, createLogger, defineAdapter, getClassMeta, getMethodMeta, reactive, ref, watch } from "@forinda/kickjs";
20
+ import { MemoryAnalyzer, PROTOCOL_VERSION, RuntimeSampler } from "@forinda/kickjs-devtools-kit";
21
+ //#region src/topology.ts
22
+ const DEFAULT_INTROSPECT_TIMEOUT_MS = 100;
23
+ /**
24
+ * Collect a {@link TopologySnapshot} for the given application.
25
+ *
26
+ * Async because plugin/adapter `introspect()` may be async; the work
27
+ * is parallelised across all primitives so wall-clock time scales
28
+ * with the slowest introspector, not their sum.
29
+ */
30
+ async function collectTopologySnapshot(opts) {
31
+ const { app, container, introspectTimeoutMs = DEFAULT_INTROSPECT_TIMEOUT_MS } = opts;
32
+ const errors = [];
33
+ const plugins = await Promise.all(app.getPlugins().map((p) => snapshotFor(p, "plugin", introspectTimeoutMs, errors)));
34
+ const adapters = await Promise.all(app.getAdapters().map((a) => snapshotFor(a, "adapter", introspectTimeoutMs, errors)));
35
+ return {
36
+ protocolVersion: PROTOCOL_VERSION,
37
+ timestamp: Date.now(),
38
+ plugins,
39
+ adapters,
40
+ contributors: collectContributors(app),
41
+ diTokens: collectTokens(container),
42
+ errors
43
+ };
44
+ }
45
+ /**
46
+ * Coerce one primitive into an {@link IntrospectionSnapshot}. Tries
47
+ * the primitive's own `introspect()` first; falls back to a stub on
48
+ * any of: missing implementation, throw, timeout, or non-object return.
49
+ */
50
+ async function snapshotFor(primitive, kind, timeoutMs, errors) {
51
+ const name = primitive.name ?? "(unnamed)";
52
+ const stub = {
53
+ protocolVersion: PROTOCOL_VERSION,
54
+ name,
55
+ kind
56
+ };
57
+ if (typeof primitive.introspect !== "function") return stub;
58
+ try {
59
+ const result = await Promise.race([Promise.resolve(primitive.introspect()), new Promise((_, reject) => setTimeout(() => reject(/* @__PURE__ */ new Error(`introspect() timed out after ${timeoutMs}ms`)), timeoutMs))]);
60
+ if (result && typeof result === "object") {
61
+ const snap = result;
62
+ return {
63
+ protocolVersion: PROTOCOL_VERSION,
64
+ name: snap.name ?? name,
65
+ kind: snap.kind ?? kind,
66
+ version: snap.version,
67
+ state: snap.state,
68
+ tokens: snap.tokens,
69
+ metrics: snap.metrics,
70
+ memoryBytes: snap.memoryBytes
71
+ };
72
+ }
73
+ return stub;
74
+ } catch (err) {
75
+ errors.push({
76
+ name,
77
+ kind,
78
+ message: err instanceof Error ? err.message : String(err)
79
+ });
80
+ return stub;
81
+ }
82
+ }
83
+ /** Read DI registrations into the topology shape — drops noisy HMR tokens. */
84
+ function collectTokens(container) {
85
+ return container.getRegistrations().filter((r) => !r.token.startsWith("__hmr__")).map((r) => ({
86
+ token: r.token,
87
+ scope: r.scope,
88
+ kind: r.kind,
89
+ instantiated: r.instantiated
90
+ }));
91
+ }
92
+ /**
93
+ * Best-effort contributor enumeration. Plugins + adapters can both
94
+ * return contributor registrations via `contributors()` — we walk both
95
+ * lists, normalise the entries, and dedupe by `key`. Module-level
96
+ * contributors (the most common case) are not enumerable from this
97
+ * surface; the panel will surface them per-route in a later iteration.
98
+ */
99
+ function collectContributors(app) {
100
+ const seen = /* @__PURE__ */ new Map();
101
+ const ingest = (list, source) => {
102
+ if (!list) return;
103
+ for (const reg of list) {
104
+ const r = reg;
105
+ const key = typeof r.key === "string" ? r.key : null;
106
+ if (!key || seen.has(key)) continue;
107
+ seen.set(key, {
108
+ key,
109
+ source,
110
+ dependsOn: Array.isArray(r.dependsOn) ? r.dependsOn : []
111
+ });
112
+ }
113
+ };
114
+ for (const plugin of app.getPlugins()) ingest(plugin.contributors?.(), "adapter");
115
+ for (const adapter of app.getAdapters()) ingest(adapter.contributors?.(), "adapter");
116
+ return [...seen.values()];
117
+ }
118
+ //#endregion
119
+ //#region src/devtools-tabs.ts
120
+ const VALID_VIEW_TYPES = new Set([
121
+ "iframe",
122
+ "launch",
123
+ "html"
124
+ ]);
125
+ /**
126
+ * Walk every plugin + adapter, call `devtoolsTabs?()`, validate each
127
+ * entry, and dedupe by `id`. Last-source-wins on collision is too
128
+ * surprising — first-source-wins + a warning entry instead.
129
+ */
130
+ function collectDevtoolsTabs(app) {
131
+ const tabs = [];
132
+ const seen = /* @__PURE__ */ new Map();
133
+ const errors = [];
134
+ const ingest = (sourceName, raw) => {
135
+ if (!Array.isArray(raw)) {
136
+ errors.push({
137
+ source: sourceName,
138
+ reason: "devtoolsTabs() did not return an array"
139
+ });
140
+ return;
141
+ }
142
+ for (const entry of raw) {
143
+ const validation = validateTab(entry);
144
+ if (!validation.ok) {
145
+ errors.push({
146
+ source: sourceName,
147
+ reason: validation.reason
148
+ });
149
+ continue;
150
+ }
151
+ const tab = validation.tab;
152
+ const owner = seen.get(tab.id);
153
+ if (owner) {
154
+ errors.push({
155
+ source: sourceName,
156
+ reason: `tab id '${tab.id}' already registered by ${owner} — first-source wins`
157
+ });
158
+ continue;
159
+ }
160
+ seen.set(tab.id, sourceName);
161
+ tabs.push(tab);
162
+ }
163
+ };
164
+ for (const plugin of app.getPlugins()) {
165
+ if (typeof plugin.devtoolsTabs !== "function") continue;
166
+ try {
167
+ ingest(plugin.name ?? "(unnamed plugin)", plugin.devtoolsTabs());
168
+ } catch (err) {
169
+ errors.push({
170
+ source: plugin.name ?? "(unnamed plugin)",
171
+ reason: err instanceof Error ? err.message : String(err)
172
+ });
173
+ }
174
+ }
175
+ for (const adapter of app.getAdapters()) {
176
+ if (typeof adapter.devtoolsTabs !== "function") continue;
177
+ try {
178
+ ingest(adapter.name ?? "(unnamed adapter)", adapter.devtoolsTabs());
179
+ } catch (err) {
180
+ errors.push({
181
+ source: adapter.name ?? "(unnamed adapter)",
182
+ reason: err instanceof Error ? err.message : String(err)
183
+ });
184
+ }
185
+ }
186
+ return {
187
+ tabs,
188
+ errors
189
+ };
190
+ }
191
+ /**
192
+ * Coerce + validate a tab descriptor. Returns the typed object on
193
+ * success; a structured reason on failure. Uses a permissive checker
194
+ * so a partially-correct entry from a third-party adapter doesn't
195
+ * break the whole panel.
196
+ */
197
+ function validateTab(raw) {
198
+ if (!raw || typeof raw !== "object") return {
199
+ ok: false,
200
+ reason: "tab entry is not an object"
201
+ };
202
+ const obj = raw;
203
+ if (typeof obj.id !== "string" || obj.id.length === 0) return {
204
+ ok: false,
205
+ reason: "tab.id must be a non-empty string"
206
+ };
207
+ if (typeof obj.title !== "string" || obj.title.length === 0) return {
208
+ ok: false,
209
+ reason: `tab '${obj.id}': title must be a non-empty string`
210
+ };
211
+ const view = obj.view;
212
+ if (!view || typeof view !== "object") return {
213
+ ok: false,
214
+ reason: `tab '${obj.id}': view object is required`
215
+ };
216
+ if (typeof view.type !== "string" || !VALID_VIEW_TYPES.has(view.type)) return {
217
+ ok: false,
218
+ reason: `tab '${obj.id}': view.type must be one of iframe / launch / html`
219
+ };
220
+ switch (view.type) {
221
+ case "iframe":
222
+ if (typeof view.src !== "string" || view.src.length === 0) return {
223
+ ok: false,
224
+ reason: `tab '${obj.id}': iframe view requires a non-empty src`
225
+ };
226
+ break;
227
+ case "html":
228
+ if (typeof view.html !== "string") return {
229
+ ok: false,
230
+ reason: `tab '${obj.id}': html view requires html string`
231
+ };
232
+ break;
233
+ case "launch":
234
+ if (!Array.isArray(view.actions)) return {
235
+ ok: false,
236
+ reason: `tab '${obj.id}': launch view requires actions array`
237
+ };
238
+ break;
239
+ }
240
+ return {
241
+ ok: true,
242
+ tab: obj
243
+ };
244
+ }
245
+ //#endregion
18
246
  //#region src/adapter.ts
19
247
  const log = createLogger("DevTools");
20
248
  const MAX_SAMPLES = 1e3;
@@ -34,6 +262,37 @@ function computePercentiles(stats) {
34
262
  };
35
263
  }
36
264
  /**
265
+ * Open an SSE response — sets the headers, flushes them, and disables
266
+ * any compression middleware that may have been registered upstream
267
+ * (compression buffers SSE chunks indefinitely; we need flush-on-write).
268
+ */
269
+ function openSseStream(req, res) {
270
+ res.writeHead(200, {
271
+ "Content-Type": "text/event-stream",
272
+ "Cache-Control": "no-cache, no-transform",
273
+ Connection: "keep-alive",
274
+ "X-Accel-Buffering": "no"
275
+ });
276
+ res.flushHeaders?.();
277
+ }
278
+ /** Write a single SSE `data:` event with a JSON payload + double newline. */
279
+ function writeSseEvent(res, payload) {
280
+ res.write(`data: ${JSON.stringify(payload)}\n\n`);
281
+ }
282
+ /**
283
+ * Module-scoped guard for `POST /_debug/memory/snapshot`. `writeHeapSnapshot()`
284
+ * blocks the event loop for the duration of the capture (multi-second
285
+ * for large heaps); two concurrent captures would compound the pause.
286
+ * The endpoint returns 503 + a hint message when this flag is set so
287
+ * the SPA can surface a "snapshot already running" warning rather than
288
+ * silently queueing.
289
+ *
290
+ * Module-scoped (not per-adapter-instance) because writeHeapSnapshot
291
+ * acts on the V8 process — multiple DevToolsAdapter instances would
292
+ * still race at the v8 layer.
293
+ */
294
+ let snapshotInProgress = false;
295
+ /**
37
296
  * DevToolsAdapter — Vue-style reactive introspection for KickJS applications.
38
297
  *
39
298
  * Exposes debug endpoints powered by reactive state (ref, computed, watch):
@@ -51,7 +310,7 @@ function computePercentiles(stats) {
51
310
  * bootstrap({
52
311
  * modules: [UserModule],
53
312
  * adapters: [
54
- * new DevToolsAdapter({
313
+ * DevToolsAdapter({
55
314
  * enabled: process.env.NODE_ENV !== 'production',
56
315
  * exposeConfig: true,
57
316
  * configPrefixes: ['APP_', 'DATABASE_'],
@@ -60,336 +319,538 @@ function computePercentiles(stats) {
60
319
  * })
61
320
  * ```
62
321
  */
63
- var DevToolsAdapter = class {
64
- name = "DevToolsAdapter";
65
- basePath;
66
- enabled;
67
- exposeConfig;
68
- configPrefixes;
69
- errorRateThreshold;
70
- secret;
71
- /** Total requests received */
72
- requestCount;
73
- /** Total responses with status >= 500 */
74
- errorCount;
75
- /** Total responses with status >= 400 and < 500 */
76
- clientErrorCount;
77
- /** Server start time */
78
- startedAt;
79
- /** Computed error rate (server errors / total requests) */
80
- errorRate;
81
- /** Computed uptime in seconds */
82
- uptimeSeconds;
83
- /** Per-route latency tracking */
84
- routeLatency;
85
- routes = [];
86
- container = null;
87
- appRef = null;
88
- adapterStatuses = {};
89
- stopErrorWatch = null;
90
- peerAdapters = [];
91
- constructor(options = {}) {
92
- this.basePath = options.basePath ?? "/_debug";
93
- this.enabled = options.enabled ?? process.env.NODE_ENV !== "production";
94
- this.exposeConfig = options.exposeConfig ?? false;
95
- this.configPrefixes = options.configPrefixes ?? ["APP_", "NODE_ENV"];
96
- this.errorRateThreshold = options.errorRateThreshold ?? .5;
97
- this.peerAdapters = options.adapters ?? [];
98
- if (options.secret === false) this.secret = false;
99
- else if (options.secret) this.secret = options.secret;
100
- else this.secret = randomBytes(16).toString("hex");
101
- this.requestCount = ref(0);
102
- this.errorCount = ref(0);
103
- this.clientErrorCount = ref(0);
104
- this.startedAt = ref(Date.now());
105
- this.routeLatency = reactive({});
106
- this.errorRate = computed(() => this.requestCount.value > 0 ? this.errorCount.value / this.requestCount.value : 0);
107
- this.uptimeSeconds = computed(() => Math.floor((Date.now() - this.startedAt.value) / 1e3));
322
+ const DevToolsAdapter = defineAdapter({
323
+ name: "DevToolsAdapter",
324
+ defaults: {
325
+ basePath: "/_debug",
326
+ errorRateThreshold: .5,
327
+ exposeConfig: false,
328
+ configPrefixes: ["APP_", "NODE_ENV"]
329
+ },
330
+ build: (options) => {
331
+ const basePath = options.basePath;
332
+ const enabled = options.enabled ?? process.env.NODE_ENV !== "production";
333
+ const exposeConfig = options.exposeConfig;
334
+ const configPrefixes = options.configPrefixes;
335
+ const errorRateThreshold = options.errorRateThreshold;
336
+ const peerAdapters = options.adapters ?? [];
337
+ let secret;
338
+ if (options.secret === false) secret = false;
339
+ else if (options.secret) secret = options.secret;
340
+ else secret = randomBytes(16).toString("hex");
341
+ const runtimeEnabled = options.runtime?.enabled ?? true;
342
+ const runtimeSampler = runtimeEnabled ? new RuntimeSampler({
343
+ intervalMs: options.runtime?.intervalMs ?? 1e3,
344
+ bufferSize: options.runtime?.bufferSize ?? 60
345
+ }) : null;
346
+ const memoryAnalyzer = runtimeEnabled ? new MemoryAnalyzer() : null;
347
+ const requestCount = ref(0);
348
+ const errorCount = ref(0);
349
+ const clientErrorCount = ref(0);
350
+ const startedAt = ref(Date.now());
351
+ const routeLatency = reactive({});
352
+ const errorRate = computed(() => requestCount.value > 0 ? errorCount.value / requestCount.value : 0);
353
+ const uptimeSeconds = computed(() => Math.floor((Date.now() - startedAt.value) / 1e3));
354
+ let routes = [];
355
+ let container = null;
356
+ let appRef = null;
357
+ const adapterStatuses = {};
358
+ let stopErrorWatch = null;
108
359
  if (options.onErrorRateExceeded) {
109
360
  const callback = options.onErrorRateExceeded;
110
- const threshold = this.errorRateThreshold;
111
- this.stopErrorWatch = watch(this.errorRate, (rate) => {
112
- if (rate > threshold) callback(rate);
361
+ stopErrorWatch = watch(errorRate, (rate) => {
362
+ if (rate > errorRateThreshold) callback(rate);
113
363
  });
114
- } else this.stopErrorWatch = watch(this.errorRate, (rate) => {
115
- if (rate > this.errorRateThreshold) log.warn(`Error rate elevated: ${(rate * 100).toFixed(1)}%`);
364
+ } else stopErrorWatch = watch(errorRate, (rate) => {
365
+ if (rate > errorRateThreshold) log.warn(`Error rate elevated: ${(rate * 100).toFixed(1)}%`);
116
366
  });
117
- }
118
- /**
119
- * Resolve peer adapters at request time. Prefers live adapters from the
120
- * Application registry (survives HMR rebuild) and falls back to the
121
- * constructor-provided refs.
122
- */
123
- getPeerAdapters() {
124
- const kickApp = this.appRef?.__kickApp;
125
- if (kickApp && typeof kickApp.getAdapters === "function") return kickApp.getAdapters();
126
- return this.peerAdapters;
127
- }
128
- beforeMount({ app, container }) {
129
- if (!this.enabled) return;
130
- this.appRef = app;
131
- this.container = container;
132
- this.startedAt.value = Date.now();
133
- this.routes = [];
134
- this.adapterStatuses[this.name] = "running";
135
- const router = Router();
136
- if (this.secret !== false) {
137
- const token = this.secret;
138
- router.use((req, res, next) => {
139
- if ((req.headers["x-devtools-token"] ?? req.query?.token) === token) return next();
140
- if (req.path === "/" && req.method === "GET" && !req.query?.token) return next();
141
- if (req.path.endsWith(".js") || req.path.endsWith(".css")) return next();
142
- res.status(403).json({ error: "Forbidden — invalid or missing devtools token" });
143
- });
144
- }
145
- router.get("/routes", (_req, res) => {
146
- res.json({ routes: this.routes });
147
- });
148
- router.get("/container", (_req, res) => {
149
- const registrations = this.container?.getRegistrations() ?? [];
150
- res.json({
151
- registrations,
152
- count: registrations.length
153
- });
154
- });
155
- router.get("/metrics", (_req, res) => {
156
- const latency = {};
157
- for (const [key, stats] of Object.entries(this.routeLatency)) {
158
- const { samples: _, ...rest } = stats;
159
- latency[key] = {
160
- ...rest,
161
- ...computePercentiles(stats)
367
+ /**
368
+ * Find the dashboard's public directory. Prefers the new Solid SPA
369
+ * (public/spa/, shipped from PR 4 of §23); falls back to the legacy
370
+ * Vue+Tailwind dashboard (public/devtools/) for environments that
371
+ * haven't run `pnpm --filter @forinda/kickjs-devtools build:spa`.
372
+ */
373
+ const resolvePublicDir = () => {
374
+ const thisDir = dirname(fileURLToPath(import.meta.url));
375
+ const candidates = [
376
+ join(thisDir, "..", "public", "spa"),
377
+ join(thisDir, "..", "..", "public", "spa"),
378
+ join(thisDir, "..", "public", "devtools"),
379
+ join(thisDir, "..", "..", "public", "devtools")
380
+ ];
381
+ for (const dir of candidates) if (existsSync(join(dir, "index.html"))) return dir;
382
+ return null;
383
+ };
384
+ /**
385
+ * Resolve peer adapters at request time. Prefers live adapters from the
386
+ * Application registry (survives HMR rebuild) and falls back to the
387
+ * constructor-provided refs.
388
+ */
389
+ const getPeerAdapters = () => {
390
+ const kickApp = appRef?.__kickApp;
391
+ if (kickApp && typeof kickApp.getAdapters === "function") return kickApp.getAdapters();
392
+ return peerAdapters;
393
+ };
394
+ return {
395
+ requestCount,
396
+ errorCount,
397
+ clientErrorCount,
398
+ startedAt,
399
+ errorRate,
400
+ uptimeSeconds,
401
+ routeLatency,
402
+ runtimeSampler,
403
+ memoryAnalyzer,
404
+ introspect() {
405
+ return {
406
+ protocolVersion: PROTOCOL_VERSION,
407
+ name: "DevToolsAdapter",
408
+ kind: "adapter",
409
+ state: {
410
+ basePath,
411
+ enabled,
412
+ runtimeEnabled,
413
+ secret: secret === false ? false : "present"
414
+ },
415
+ metrics: {
416
+ requestCount: requestCount.value,
417
+ serverErrors: errorCount.value,
418
+ clientErrors: clientErrorCount.value,
419
+ uptimeSeconds: uptimeSeconds.value,
420
+ routesTracked: routes.length
421
+ }
162
422
  };
163
- }
164
- res.json({
165
- requests: this.requestCount.value,
166
- serverErrors: this.errorCount.value,
167
- clientErrors: this.clientErrorCount.value,
168
- errorRate: this.errorRate.value,
169
- uptimeSeconds: this.uptimeSeconds.value,
170
- startedAt: new Date(this.startedAt.value).toISOString(),
171
- routeLatency: latency
172
- });
173
- });
174
- router.get("/health", (_req, res) => {
175
- const healthy = this.errorRate.value < this.errorRateThreshold;
176
- const status = healthy ? "healthy" : "degraded";
177
- res.status(healthy ? 200 : 503).json({
178
- status,
179
- errorRate: this.errorRate.value,
180
- uptime: this.uptimeSeconds.value,
181
- adapters: this.adapterStatuses
182
- });
183
- });
184
- router.get("/state", (_req, res) => {
185
- const wsAdapter = this.getPeerAdapters().find((a) => a.name === "WsAdapter" && typeof a.getStats === "function");
186
- res.json({
187
- reactive: {
188
- requestCount: this.requestCount.value,
189
- errorCount: this.errorCount.value,
190
- clientErrorCount: this.clientErrorCount.value,
191
- errorRate: this.errorRate.value,
192
- uptimeSeconds: this.uptimeSeconds.value,
193
- startedAt: new Date(this.startedAt.value).toISOString()
194
- },
195
- routes: this.routes.length,
196
- container: this.container?.getRegistrations().length ?? 0,
197
- routeLatency: this.routeLatency,
198
- ...wsAdapter ? { ws: wsAdapter.getStats() } : {}
199
- });
200
- });
201
- router.get("/ws", (_req, res) => {
202
- const wsAdapter = this.getPeerAdapters().find((a) => a.name === "WsAdapter" && typeof a.getStats === "function");
203
- if (!wsAdapter) {
204
- res.json({
205
- enabled: false,
206
- message: "WsAdapter not found"
423
+ },
424
+ beforeMount({ app, container: containerArg }) {
425
+ if (!enabled) return;
426
+ appRef = app;
427
+ container = containerArg;
428
+ startedAt.value = Date.now();
429
+ routes = [];
430
+ adapterStatuses["DevToolsAdapter"] = "running";
431
+ runtimeSampler?.start();
432
+ memoryAnalyzer?.start();
433
+ const router = Router();
434
+ if (secret !== false) {
435
+ const token = secret;
436
+ router.use((req, res, next) => {
437
+ if ((req.headers["x-devtools-token"] ?? req.query?.token) === token) return next();
438
+ if (req.path === "/" && req.method === "GET" && !req.query?.token) return next();
439
+ if (req.path.endsWith(".js") || req.path.endsWith(".css")) return next();
440
+ res.status(403).json({ error: "Forbidden — invalid or missing devtools token" });
441
+ });
442
+ }
443
+ router.get("/routes", (_req, res) => {
444
+ res.json({ routes });
207
445
  });
208
- return;
209
- }
210
- res.json({
211
- enabled: true,
212
- ...wsAdapter.getStats()
213
- });
214
- });
215
- router.get("/queues", async (_req, res) => {
216
- const queueAdapter = this.getPeerAdapters().find((a) => a.name === "QueueAdapter" && typeof a.getQueueNames === "function");
217
- if (!queueAdapter) {
218
- res.json({
219
- enabled: false,
220
- message: "QueueAdapter not found"
446
+ router.get("/container", (_req, res) => {
447
+ const registrations = container?.getRegistrations() ?? [];
448
+ res.json({
449
+ registrations,
450
+ count: registrations.length
451
+ });
221
452
  });
222
- return;
223
- }
224
- try {
225
- const names = queueAdapter.getQueueNames?.() ?? [];
226
- const queues = [];
227
- for (const name of names) {
228
- const stats = await queueAdapter.getQueueStats?.(name);
229
- queues.push({
230
- name,
231
- ...stats
453
+ router.get("/metrics", (_req, res) => {
454
+ const latency = {};
455
+ for (const [key, stats] of Object.entries(routeLatency)) {
456
+ const { samples: _, ...rest } = stats;
457
+ latency[key] = {
458
+ ...rest,
459
+ ...computePercentiles(stats)
460
+ };
461
+ }
462
+ res.json({
463
+ requests: requestCount.value,
464
+ serverErrors: errorCount.value,
465
+ clientErrors: clientErrorCount.value,
466
+ errorRate: errorRate.value,
467
+ uptimeSeconds: uptimeSeconds.value,
468
+ startedAt: new Date(startedAt.value).toISOString(),
469
+ routeLatency: latency
232
470
  });
233
- }
234
- res.json({
235
- enabled: true,
236
- queues
237
471
  });
238
- } catch {
239
- res.json({
240
- enabled: true,
241
- queues: [],
242
- error: "Failed to fetch queue stats"
472
+ router.get("/health", (_req, res) => {
473
+ const healthy = errorRate.value < errorRateThreshold;
474
+ const status = healthy ? "healthy" : "degraded";
475
+ res.status(healthy ? 200 : 503).json({
476
+ status,
477
+ errorRate: errorRate.value,
478
+ uptime: uptimeSeconds.value,
479
+ adapters: adapterStatuses
480
+ });
243
481
  });
244
- }
245
- });
246
- router.get("/graph", (_req, res) => {
247
- const registrations = this.container?.getRegistrations() ?? [];
248
- const nodes = registrations.filter((r) => !r.token.startsWith("__hmr__")).map((r) => ({
249
- id: r.token,
250
- kind: r.kind,
251
- scope: r.scope,
252
- resolveCount: r.resolveCount
253
- }));
254
- const nodeIds = new Set(nodes.map((n) => n.id));
255
- const edges = [];
256
- for (const r of registrations) {
257
- if (r.token.startsWith("__hmr__")) continue;
258
- for (const dep of r.dependencies) if (nodeIds.has(dep)) edges.push({
259
- from: r.token,
260
- to: dep
482
+ router.get("/state", (_req, res) => {
483
+ const wsAdapter = getPeerAdapters().find((a) => a.name === "WsAdapter" && typeof a.getStats === "function");
484
+ res.json({
485
+ reactive: {
486
+ requestCount: requestCount.value,
487
+ errorCount: errorCount.value,
488
+ clientErrorCount: clientErrorCount.value,
489
+ errorRate: errorRate.value,
490
+ uptimeSeconds: uptimeSeconds.value,
491
+ startedAt: new Date(startedAt.value).toISOString()
492
+ },
493
+ routes: routes.length,
494
+ container: container?.getRegistrations().length ?? 0,
495
+ routeLatency,
496
+ ...wsAdapter ? { ws: wsAdapter.getStats() } : {}
497
+ });
261
498
  });
262
- }
263
- res.json({
264
- nodes,
265
- edges
266
- });
267
- });
268
- router.get("/stream", (req, res) => {
269
- res.writeHead(200, {
270
- "Content-Type": "text/event-stream",
271
- "Cache-Control": "no-cache",
272
- Connection: "keep-alive"
273
- });
274
- const sendMetrics = () => {
275
- const data = {
276
- type: "metrics",
277
- requestCount: this.requestCount.value,
278
- errorCount: this.errorCount.value,
279
- clientErrorCount: this.clientErrorCount.value,
280
- errorRate: this.errorRate.value,
281
- uptimeSeconds: this.uptimeSeconds.value
282
- };
283
- res.write(`data: ${JSON.stringify(data)}\n\n`);
284
- };
285
- sendMetrics();
286
- const unsubContainer = this.container?.onChange?.((changes) => {
287
- res.write(`data: ${JSON.stringify({
288
- type: "container",
289
- changes,
290
- timestamp: Date.now()
291
- })}\n\n`);
292
- sendMetrics();
293
- });
294
- const stopRequestWatch = watch(this.requestCount, () => sendMetrics());
295
- const stopErrorWatch = watch(this.errorCount, () => sendMetrics());
296
- const heartbeat = setInterval(() => {
297
- res.write(`: heartbeat\n\n`);
298
- }, 3e4);
299
- req.on("close", () => {
300
- unsubContainer?.();
301
- stopRequestWatch();
302
- stopErrorWatch();
303
- clearInterval(heartbeat);
304
- });
305
- });
306
- if (this.exposeConfig) router.get("/config", (_req, res) => {
307
- const config = {};
308
- for (const [key, value] of Object.entries(process.env)) {
309
- if (value === void 0) continue;
310
- config[key] = this.configPrefixes.some((prefix) => key.startsWith(prefix)) ? value : "[REDACTED]";
311
- }
312
- res.json({ config });
313
- });
314
- const publicDir = this.resolvePublicDir();
315
- if (publicDir) {
316
- router.use(static$1(publicDir));
317
- const indexHtml = readFileSync(join(publicDir, "index.html"), "utf-8");
318
- router.get("/", (_req, res) => {
319
- const html = indexHtml.replace("<body", `<body data-base="${this.basePath}"`);
320
- res.type("html").send(html);
321
- });
322
- } else router.get("/", (_req, res) => {
323
- res.type("html").send("<h1>DevTools: public directory not found</h1>");
324
- });
325
- app.use(this.basePath, router);
326
- if (this.secret) {
327
- log.info(`DevTools mounted at ${this.basePath} [token: ${this.secret}]`);
328
- log.info(`Access: ${this.basePath}?token=${this.secret}`);
329
- } else log.info(`DevTools mounted at ${this.basePath} [no guard]`);
330
- }
331
- middleware() {
332
- if (!this.enabled) return [];
333
- return [{
334
- handler: (req, res, next) => {
335
- const start = Date.now();
336
- this.requestCount.value++;
337
- res.on("finish", () => {
338
- if (res.statusCode >= 500) this.errorCount.value++;
339
- else if (res.statusCode >= 400) this.clientErrorCount.value++;
340
- const routeKey = `${req.method} ${req.route?.path ?? req.path}`;
341
- const elapsed = Date.now() - start;
342
- if (!this.routeLatency[routeKey]) this.routeLatency[routeKey] = {
343
- count: 0,
344
- totalMs: 0,
345
- minMs: Infinity,
346
- maxMs: 0,
347
- samples: []
499
+ router.get("/runtime", (_req, res) => {
500
+ if (!runtimeSampler || !memoryAnalyzer) {
501
+ res.status(404).json({ error: "runtime sampler disabled — set runtime.enabled = true" });
502
+ return;
503
+ }
504
+ const latest = runtimeSampler.latest();
505
+ const history = runtimeSampler.history();
506
+ const health = memoryAnalyzer.health(history);
507
+ res.json({
508
+ protocolVersion: PROTOCOL_VERSION,
509
+ latest,
510
+ history,
511
+ health
512
+ });
513
+ });
514
+ router.get("/runtime/stream", (req, res) => {
515
+ if (!runtimeSampler) {
516
+ res.status(404).json({ error: "runtime sampler disabled" });
517
+ return;
518
+ }
519
+ openSseStream(req, res);
520
+ const intervalMs = options.runtime?.intervalMs ?? 1e3;
521
+ const tick = () => {
522
+ const snap = runtimeSampler.latest();
523
+ if (snap) writeSseEvent(res, snap);
348
524
  };
349
- const stats = this.routeLatency[routeKey];
350
- stats.count++;
351
- stats.totalMs += elapsed;
352
- stats.minMs = Math.min(stats.minMs, elapsed);
353
- stats.maxMs = Math.max(stats.maxMs, elapsed);
354
- stats.samples.push(elapsed);
355
- if (stats.samples.length > MAX_SAMPLES) stats.samples.shift();
525
+ tick();
526
+ const interval = setInterval(tick, intervalMs);
527
+ const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 3e4);
528
+ req.on("close", () => {
529
+ clearInterval(interval);
530
+ clearInterval(heartbeat);
531
+ });
356
532
  });
357
- next();
533
+ router.get("/memory/stream", (req, res) => {
534
+ if (!runtimeSampler || !memoryAnalyzer) {
535
+ res.status(404).json({ error: "runtime sampler disabled" });
536
+ return;
537
+ }
538
+ openSseStream(req, res);
539
+ const intervalMs = options.runtime?.intervalMs ?? 1e3;
540
+ const tick = () => {
541
+ const snap = runtimeSampler.latest();
542
+ if (!snap) return;
543
+ writeSseEvent(res, {
544
+ snapshot: snap,
545
+ health: memoryAnalyzer.health(runtimeSampler.history())
546
+ });
547
+ };
548
+ tick();
549
+ const interval = setInterval(tick, intervalMs);
550
+ const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), 3e4);
551
+ req.on("close", () => {
552
+ clearInterval(interval);
553
+ clearInterval(heartbeat);
554
+ });
555
+ });
556
+ router.post("/memory/snapshot", (_req, res) => {
557
+ if (snapshotInProgress) {
558
+ res.status(503).json({ error: "heap snapshot already in progress — wait for it to complete" });
559
+ return;
560
+ }
561
+ snapshotInProgress = true;
562
+ const startedAt = Date.now();
563
+ const filename = `kickjs-heap-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.heapsnapshot`;
564
+ const targetPath = join(tmpdir(), filename);
565
+ let written;
566
+ try {
567
+ written = writeHeapSnapshot(targetPath);
568
+ } catch (err) {
569
+ snapshotInProgress = false;
570
+ log.error({ err }, "heap snapshot capture failed");
571
+ res.status(500).json({
572
+ error: "snapshot capture failed",
573
+ message: err.message
574
+ });
575
+ return;
576
+ }
577
+ let size = 0;
578
+ try {
579
+ size = statSync(written).size;
580
+ } catch {}
581
+ const elapsedMs = Date.now() - startedAt;
582
+ log.info(`Heap snapshot captured: ${written} (${(size / 1024 / 1024).toFixed(1)} MiB in ${elapsedMs}ms)`);
583
+ res.setHeader("Content-Type", "application/json");
584
+ res.setHeader("Content-Disposition", `attachment; filename="${filename}"`);
585
+ if (size > 0) res.setHeader("Content-Length", String(size));
586
+ res.setHeader("X-Snapshot-Capture-Ms", String(elapsedMs));
587
+ const cleanup = () => {
588
+ unlink(written, (err) => {
589
+ if (err) log.warn(`Failed to delete heap snapshot ${written}: ${err.message}`);
590
+ snapshotInProgress = false;
591
+ });
592
+ };
593
+ const stream = createReadStream(written);
594
+ stream.on("end", cleanup);
595
+ stream.on("error", (err) => {
596
+ log.error({ err }, "heap snapshot stream failed");
597
+ if (!res.headersSent) res.status(500).end();
598
+ cleanup();
599
+ });
600
+ res.on("close", () => {
601
+ stream.destroy();
602
+ });
603
+ stream.pipe(res);
604
+ });
605
+ router.post("/memory/gc", (_req, res) => {
606
+ const gc = globalThis.gc;
607
+ if (typeof gc !== "function") {
608
+ res.status(412).json({ error: "global.gc unavailable — start Node with --expose-gc to enable this endpoint" });
609
+ return;
610
+ }
611
+ const before = process.memoryUsage().heapUsed;
612
+ const startedAt = Date.now();
613
+ try {
614
+ gc();
615
+ } catch (err) {
616
+ res.status(500).json({
617
+ error: "forced GC failed",
618
+ message: err instanceof Error ? err.message : String(err)
619
+ });
620
+ return;
621
+ }
622
+ const after = process.memoryUsage().heapUsed;
623
+ const reclaimedBytes = Math.max(0, before - after);
624
+ const elapsedMs = Date.now() - startedAt;
625
+ log.info(`Forced GC reclaimed ${(reclaimedBytes / 1024 / 1024).toFixed(2)} MiB in ${elapsedMs}ms`);
626
+ res.json({
627
+ before,
628
+ after,
629
+ reclaimedBytes,
630
+ elapsedMs
631
+ });
632
+ });
633
+ router.get("/tabs", (_req, res) => {
634
+ const kickApp = appRef?.__kickApp;
635
+ if (!kickApp) {
636
+ res.status(503).json({ error: "tabs unavailable — application surface not exposed" });
637
+ return;
638
+ }
639
+ res.json(collectDevtoolsTabs(kickApp));
640
+ });
641
+ router.get("/topology", async (_req, res) => {
642
+ if (!container) {
643
+ res.status(503).json({ error: "topology unavailable — container not bound yet" });
644
+ return;
645
+ }
646
+ const kickApp = appRef?.__kickApp;
647
+ if (!kickApp) {
648
+ res.status(503).json({ error: "topology unavailable — application surface not exposed" });
649
+ return;
650
+ }
651
+ try {
652
+ const snapshot = await collectTopologySnapshot({
653
+ app: kickApp,
654
+ container
655
+ });
656
+ res.json(snapshot);
657
+ } catch (err) {
658
+ const message = err instanceof Error ? err.message : String(err);
659
+ res.status(500).json({
660
+ error: "topology collection failed",
661
+ message
662
+ });
663
+ }
664
+ });
665
+ router.get("/ws", (_req, res) => {
666
+ const wsAdapter = getPeerAdapters().find((a) => a.name === "WsAdapter" && typeof a.getStats === "function");
667
+ if (!wsAdapter) {
668
+ res.json({
669
+ enabled: false,
670
+ message: "WsAdapter not found"
671
+ });
672
+ return;
673
+ }
674
+ res.json({
675
+ enabled: true,
676
+ ...wsAdapter.getStats()
677
+ });
678
+ });
679
+ router.get("/queues", async (_req, res) => {
680
+ const queueAdapter = getPeerAdapters().find((a) => a.name === "QueueAdapter" && typeof a.getQueueNames === "function");
681
+ if (!queueAdapter) {
682
+ res.json({
683
+ enabled: false,
684
+ message: "QueueAdapter not found"
685
+ });
686
+ return;
687
+ }
688
+ try {
689
+ const names = queueAdapter.getQueueNames?.() ?? [];
690
+ const queues = [];
691
+ for (const name of names) {
692
+ const stats = await queueAdapter.getQueueStats?.(name);
693
+ queues.push({
694
+ name,
695
+ ...stats
696
+ });
697
+ }
698
+ res.json({
699
+ enabled: true,
700
+ queues
701
+ });
702
+ } catch {
703
+ res.json({
704
+ enabled: true,
705
+ queues: [],
706
+ error: "Failed to fetch queue stats"
707
+ });
708
+ }
709
+ });
710
+ router.get("/graph", (_req, res) => {
711
+ const registrations = container?.getRegistrations() ?? [];
712
+ const nodes = registrations.filter((r) => !r.token.startsWith("__hmr__")).map((r) => ({
713
+ id: r.token,
714
+ kind: r.kind,
715
+ scope: r.scope,
716
+ resolveCount: r.resolveCount
717
+ }));
718
+ const nodeIds = new Set(nodes.map((n) => n.id));
719
+ const edges = [];
720
+ for (const r of registrations) {
721
+ if (r.token.startsWith("__hmr__")) continue;
722
+ for (const dep of r.dependencies) if (nodeIds.has(dep)) edges.push({
723
+ from: r.token,
724
+ to: dep
725
+ });
726
+ }
727
+ res.json({
728
+ nodes,
729
+ edges
730
+ });
731
+ });
732
+ router.get("/stream", (req, res) => {
733
+ res.writeHead(200, {
734
+ "Content-Type": "text/event-stream",
735
+ "Cache-Control": "no-cache",
736
+ Connection: "keep-alive"
737
+ });
738
+ const sendMetrics = () => {
739
+ const data = {
740
+ type: "metrics",
741
+ requestCount: requestCount.value,
742
+ errorCount: errorCount.value,
743
+ clientErrorCount: clientErrorCount.value,
744
+ errorRate: errorRate.value,
745
+ uptimeSeconds: uptimeSeconds.value
746
+ };
747
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
748
+ };
749
+ sendMetrics();
750
+ const unsubContainer = container?.onChange?.((changes) => {
751
+ res.write(`data: ${JSON.stringify({
752
+ type: "container",
753
+ changes,
754
+ timestamp: Date.now()
755
+ })}\n\n`);
756
+ sendMetrics();
757
+ });
758
+ const stopRequestWatch = watch(requestCount, () => sendMetrics());
759
+ const stopErrWatch = watch(errorCount, () => sendMetrics());
760
+ const heartbeat = setInterval(() => {
761
+ res.write(`: heartbeat\n\n`);
762
+ }, 3e4);
763
+ req.on("close", () => {
764
+ unsubContainer?.();
765
+ stopRequestWatch();
766
+ stopErrWatch();
767
+ clearInterval(heartbeat);
768
+ });
769
+ });
770
+ if (exposeConfig) router.get("/config", (_req, res) => {
771
+ const config = {};
772
+ for (const [key, value] of Object.entries(process.env)) {
773
+ if (value === void 0) continue;
774
+ config[key] = configPrefixes.some((prefix) => key.startsWith(prefix)) ? value : "[REDACTED]";
775
+ }
776
+ res.json({ config });
777
+ });
778
+ const publicDir = resolvePublicDir();
779
+ if (publicDir) {
780
+ router.use(static$1(publicDir));
781
+ const indexHtml = readFileSync(join(publicDir, "index.html"), "utf-8");
782
+ router.get("/", (_req, res) => {
783
+ const html = indexHtml.replace("<body", `<body data-base="${basePath}"`);
784
+ res.type("html").send(html);
785
+ });
786
+ } else router.get("/", (_req, res) => {
787
+ res.type("html").send("<h1>DevTools: public directory not found</h1>");
788
+ });
789
+ app.use(basePath, router);
790
+ if (secret) {
791
+ log.info(`DevTools mounted at ${basePath} [token: ${secret}]`);
792
+ log.info(`Access: ${basePath}?token=${secret}`);
793
+ } else log.info(`DevTools mounted at ${basePath} [no guard]`);
358
794
  },
359
- phase: "beforeGlobal"
360
- }];
361
- }
362
- onRouteMount(controllerClass, mountPath) {
363
- if (!this.enabled) return;
364
- const routes = getClassMeta(METADATA.ROUTES, controllerClass, []);
365
- const classMiddleware = getClassMeta(METADATA.CLASS_MIDDLEWARES, controllerClass, []);
366
- for (const route of routes) {
367
- const methodMiddleware = getMethodMeta(METADATA.METHOD_MIDDLEWARES, controllerClass.prototype, route.handlerName, []);
368
- this.routes.push({
369
- method: route.method.toUpperCase(),
370
- path: `${mountPath}${route.path === "/" ? "" : route.path}`,
371
- controller: controllerClass.name,
372
- handler: route.handlerName,
373
- middleware: [...classMiddleware.map((m) => m.name || "anonymous"), ...methodMiddleware.map((m) => m.name || "anonymous")]
374
- });
375
- }
376
- }
377
- afterStart({}) {
378
- if (!this.enabled) return;
379
- log.info(`DevTools ready — ${this.routes.length} routes tracked, ${this.container?.getRegistrations().length ?? 0} DI bindings`);
380
- }
381
- shutdown() {
382
- this.stopErrorWatch?.();
383
- this.adapterStatuses[this.name] = "stopped";
384
- }
385
- /** Find the public/devtools directory relative to the built dist or source */
386
- resolvePublicDir() {
387
- const thisDir = dirname(fileURLToPath(import.meta.url));
388
- const candidates = [join(thisDir, "..", "public", "devtools"), join(thisDir, "..", "..", "public", "devtools")];
389
- for (const dir of candidates) if (existsSync(join(dir, "index.html"))) return dir;
390
- return null;
795
+ middleware() {
796
+ if (!enabled) return [];
797
+ return [{
798
+ handler: (req, res, next) => {
799
+ const start = Date.now();
800
+ requestCount.value++;
801
+ res.on("finish", () => {
802
+ if (res.statusCode >= 500) errorCount.value++;
803
+ else if (res.statusCode >= 400) clientErrorCount.value++;
804
+ const routeKey = `${req.method} ${req.route?.path ?? req.path}`;
805
+ const elapsed = Date.now() - start;
806
+ if (!routeLatency[routeKey]) routeLatency[routeKey] = {
807
+ count: 0,
808
+ totalMs: 0,
809
+ minMs: Infinity,
810
+ maxMs: 0,
811
+ samples: []
812
+ };
813
+ const stats = routeLatency[routeKey];
814
+ stats.count++;
815
+ stats.totalMs += elapsed;
816
+ stats.minMs = Math.min(stats.minMs, elapsed);
817
+ stats.maxMs = Math.max(stats.maxMs, elapsed);
818
+ stats.samples.push(elapsed);
819
+ if (stats.samples.length > MAX_SAMPLES) stats.samples.shift();
820
+ });
821
+ next();
822
+ },
823
+ phase: "beforeGlobal"
824
+ }];
825
+ },
826
+ onRouteMount(controllerClass, mountPath) {
827
+ if (!enabled) return;
828
+ const collectedRoutes = getClassMeta(METADATA.ROUTES, controllerClass, []);
829
+ const classMiddleware = getClassMeta(METADATA.CLASS_MIDDLEWARES, controllerClass, []);
830
+ for (const route of collectedRoutes) {
831
+ const methodMiddleware = getMethodMeta(METADATA.METHOD_MIDDLEWARES, controllerClass.prototype, route.handlerName, []);
832
+ routes.push({
833
+ method: route.method.toUpperCase(),
834
+ path: `${mountPath}${route.path === "/" ? "" : route.path}`,
835
+ controller: controllerClass.name,
836
+ handler: route.handlerName,
837
+ middleware: [...classMiddleware.map((m) => m.name || "anonymous"), ...methodMiddleware.map((m) => m.name || "anonymous")]
838
+ });
839
+ }
840
+ },
841
+ afterStart() {
842
+ if (!enabled) return;
843
+ log.info(`DevTools ready — ${routes.length} routes tracked, ${container?.getRegistrations().length ?? 0} DI bindings`);
844
+ },
845
+ shutdown() {
846
+ stopErrorWatch?.();
847
+ runtimeSampler?.stop();
848
+ memoryAnalyzer?.stop();
849
+ adapterStatuses["DevToolsAdapter"] = "stopped";
850
+ }
851
+ };
391
852
  }
392
- };
853
+ });
393
854
  //#endregion
394
855
  export { DevToolsAdapter };
395
856