@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 +9 -981
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/public/spa/assets/index.js +1 -81
package/dist/index.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @forinda/kickjs-devtools v5.2.
|
|
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
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|