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