@forinda/kickjs-devtools 5.2.1 → 5.2.3

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