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