opticore-profiler 1.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/LICENSE +21 -0
- package/README.md +99 -0
- package/dist/assets/css/debug-message.css +16 -0
- package/dist/assets/css/home-page.css +177 -0
- package/dist/assets/css/profiler-detail.css +215 -0
- package/dist/assets/css/profiler-list.css +131 -0
- package/dist/assets/css/toolbar-standalone.css +213 -0
- package/dist/assets/js/home-particles.js +65 -0
- package/dist/assets/js/profiler-detail.js +77 -0
- package/dist/assets/js/profiler-list.js +76 -0
- package/dist/assets/js/toolbar.js +230 -0
- package/dist/index.cjs +1942 -0
- package/dist/index.d.cts +488 -0
- package/dist/index.d.ts +488 -0
- package/dist/index.js +1893 -0
- package/dist/utils/translations/message.translation.en.json +190 -0
- package/dist/utils/translations/message.translation.fr.json +190 -0
- package/dist/views/templates/home.njk +120 -0
- package/dist/views/templates/macros/icons.njk +19 -0
- package/dist/views/templates/macros/tables.njk +17 -0
- package/dist/views/templates/message.njk +14 -0
- package/dist/views/templates/panels/configuration.njk +16 -0
- package/dist/views/templates/panels/database.njk +36 -0
- package/dist/views/templates/panels/exception.njk +26 -0
- package/dist/views/templates/panels/logs.njk +44 -0
- package/dist/views/templates/panels/performance.njk +56 -0
- package/dist/views/templates/panels/request.njk +141 -0
- package/dist/views/templates/panels/routes.njk +51 -0
- package/dist/views/templates/panels/routing.njk +22 -0
- package/dist/views/templates/profiler-detail.njk +114 -0
- package/dist/views/templates/profiler-list.njk +163 -0
- package/dist/views/templates/toolbar-wdt.njk +161 -0
- package/package.json +73 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1942 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __export = (target, all) => {
|
|
9
|
+
for (var name in all)
|
|
10
|
+
__defProp(target, name, { get: all[name], enumerable: true });
|
|
11
|
+
};
|
|
12
|
+
var __copyProps = (to, from, except, desc) => {
|
|
13
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
14
|
+
for (let key of __getOwnPropNames(from))
|
|
15
|
+
if (!__hasOwnProp.call(to, key) && key !== except)
|
|
16
|
+
__defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
|
|
17
|
+
}
|
|
18
|
+
return to;
|
|
19
|
+
};
|
|
20
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(
|
|
21
|
+
// If the importer is in node compatibility mode or this is not an ESM
|
|
22
|
+
// file that has been converted to a CommonJS file using a Babel-
|
|
23
|
+
// compatible transform (i.e. "__esModule" has not been set), then set
|
|
24
|
+
// "default" to the CommonJS "module.exports" for node compatibility.
|
|
25
|
+
isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target,
|
|
26
|
+
mod
|
|
27
|
+
));
|
|
28
|
+
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
29
|
+
|
|
30
|
+
// src/index.ts
|
|
31
|
+
var index_exports = {};
|
|
32
|
+
__export(index_exports, {
|
|
33
|
+
CollectorRegistry: () => CollectorRegistry,
|
|
34
|
+
DEFAULT_EXCLUDE_PATHS: () => DEFAULT_EXCLUDE_PATHS,
|
|
35
|
+
DEFAULT_SENSITIVE_KEYS: () => DEFAULT_SENSITIVE_KEYS,
|
|
36
|
+
DatabaseCollector: () => DatabaseCollector,
|
|
37
|
+
ExceptionCollector: () => ExceptionCollector,
|
|
38
|
+
FileStorage: () => FileStorage,
|
|
39
|
+
LoggerCollector: () => LoggerCollector,
|
|
40
|
+
MemoryCollector: () => MemoryCollector,
|
|
41
|
+
MemoryStorage: () => MemoryStorage,
|
|
42
|
+
RequestCollector: () => RequestCollector,
|
|
43
|
+
TimeCollector: () => TimeCollector,
|
|
44
|
+
createProfilerRouter: () => createProfilerRouter,
|
|
45
|
+
generateToken: () => generateToken,
|
|
46
|
+
getActiveCollector: () => getActiveCollector,
|
|
47
|
+
instrumentLogger: () => instrumentLogger,
|
|
48
|
+
instrumentMySQL: () => instrumentMySQL,
|
|
49
|
+
isValidToken: () => isValidToken,
|
|
50
|
+
opticoreProfiler: () => opticoreProfiler,
|
|
51
|
+
profilerErrorHandler: () => profilerErrorHandler,
|
|
52
|
+
registerProfilerViews: () => registerProfilerViews
|
|
53
|
+
});
|
|
54
|
+
module.exports = __toCommonJS(index_exports);
|
|
55
|
+
|
|
56
|
+
// src/middleware/profiler.middleware.ts
|
|
57
|
+
var import_events = require("events");
|
|
58
|
+
|
|
59
|
+
// src/token.ts
|
|
60
|
+
var import_crypto = require("crypto");
|
|
61
|
+
var ALPHABET = "abcdefghijklmnopqrstuvwxyz0123456789";
|
|
62
|
+
function generateToken(length = 8) {
|
|
63
|
+
if (length < 6 || length > 8) {
|
|
64
|
+
throw new Error("Profiler token length must be between 6 and 8 characters");
|
|
65
|
+
}
|
|
66
|
+
let out = "";
|
|
67
|
+
while (out.length < length) {
|
|
68
|
+
const bytes = (0, import_crypto.randomBytes)(length);
|
|
69
|
+
for (let i = 0; i < bytes.length && out.length < length; i++) {
|
|
70
|
+
const byte = bytes[i];
|
|
71
|
+
if (byte < 252) {
|
|
72
|
+
out += ALPHABET[byte % 36];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
function isValidToken(token) {
|
|
79
|
+
return typeof token === "string" && /^[a-z0-9]{6,8}$/.test(token);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// src/context.ts
|
|
83
|
+
var import_async_hooks = require("async_hooks");
|
|
84
|
+
var profilerContext = new import_async_hooks.AsyncLocalStorage();
|
|
85
|
+
function runWithProfilerContext(state, fn) {
|
|
86
|
+
return profilerContext.run(state, fn);
|
|
87
|
+
}
|
|
88
|
+
function getActiveProfilingState() {
|
|
89
|
+
return profilerContext.getStore();
|
|
90
|
+
}
|
|
91
|
+
function getActiveCollector(name) {
|
|
92
|
+
return profilerContext.getStore()?.collectors.get(name);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// src/registry.ts
|
|
96
|
+
var CollectorRegistry = class {
|
|
97
|
+
factories = /* @__PURE__ */ new Map();
|
|
98
|
+
register(factory) {
|
|
99
|
+
const probe = factory();
|
|
100
|
+
const name = probe.getName();
|
|
101
|
+
if (!name) {
|
|
102
|
+
throw new Error("DataCollector.getName() must return a non-empty string");
|
|
103
|
+
}
|
|
104
|
+
this.factories.set(name, factory);
|
|
105
|
+
}
|
|
106
|
+
unregister(name) {
|
|
107
|
+
this.factories.delete(name);
|
|
108
|
+
}
|
|
109
|
+
has(name) {
|
|
110
|
+
return this.factories.has(name);
|
|
111
|
+
}
|
|
112
|
+
names() {
|
|
113
|
+
return [...this.factories.keys()];
|
|
114
|
+
}
|
|
115
|
+
/** Builds one fresh instance per registered factory, keyed by collector name. */
|
|
116
|
+
createAll() {
|
|
117
|
+
const instances = /* @__PURE__ */ new Map();
|
|
118
|
+
for (const [name, factory] of this.factories) {
|
|
119
|
+
instances.set(name, factory());
|
|
120
|
+
}
|
|
121
|
+
return instances;
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
// src/types.ts
|
|
126
|
+
var DEFAULT_EXCLUDE_PATHS = ["/favicon.ico", "/.well-known/*"];
|
|
127
|
+
var DEFAULT_SENSITIVE_KEYS = [
|
|
128
|
+
"authorization",
|
|
129
|
+
"cookie",
|
|
130
|
+
"set-cookie",
|
|
131
|
+
"x-api-key",
|
|
132
|
+
"session",
|
|
133
|
+
"password",
|
|
134
|
+
"passwd",
|
|
135
|
+
"secret",
|
|
136
|
+
"token",
|
|
137
|
+
"access_token",
|
|
138
|
+
"refresh_token",
|
|
139
|
+
"api_key",
|
|
140
|
+
"apikey",
|
|
141
|
+
"private_key"
|
|
142
|
+
];
|
|
143
|
+
|
|
144
|
+
// src/security/masker.ts
|
|
145
|
+
var REDACTED = "\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022";
|
|
146
|
+
var Masker = class {
|
|
147
|
+
keys;
|
|
148
|
+
constructor(sensitiveKeys = DEFAULT_SENSITIVE_KEYS) {
|
|
149
|
+
this.keys = new Set(sensitiveKeys.map((k) => k.toLowerCase()));
|
|
150
|
+
}
|
|
151
|
+
isSensitive(key) {
|
|
152
|
+
const k = key.toLowerCase();
|
|
153
|
+
for (const sensitive of this.keys) {
|
|
154
|
+
if (k === sensitive || k.includes(sensitive)) return true;
|
|
155
|
+
}
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
/** Shallow-masks a flat object (headers, query, cookies, single-level body). */
|
|
159
|
+
maskShallow(obj) {
|
|
160
|
+
const result = {};
|
|
161
|
+
for (const [key, value] of Object.entries(obj ?? {})) {
|
|
162
|
+
result[key] = this.isSensitive(key) ? REDACTED : value;
|
|
163
|
+
}
|
|
164
|
+
return result;
|
|
165
|
+
}
|
|
166
|
+
/** Recursively masks nested objects/arrays (request/response bodies). */
|
|
167
|
+
maskDeep(value, depth = 0) {
|
|
168
|
+
if (depth > 8 || value === null || typeof value !== "object") return value;
|
|
169
|
+
if (Array.isArray(value)) {
|
|
170
|
+
return value.map((v) => this.maskDeep(v, depth + 1));
|
|
171
|
+
}
|
|
172
|
+
const out = {};
|
|
173
|
+
for (const [key, v] of Object.entries(value)) {
|
|
174
|
+
out[key] = this.isSensitive(key) ? REDACTED : this.maskDeep(v, depth + 1);
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
};
|
|
179
|
+
|
|
180
|
+
// src/collectors/request.collector.ts
|
|
181
|
+
var HTTP_STATUS_MESSAGES = {
|
|
182
|
+
100: "Continue",
|
|
183
|
+
101: "Switching Protocols",
|
|
184
|
+
200: "OK",
|
|
185
|
+
201: "Created",
|
|
186
|
+
202: "Accepted",
|
|
187
|
+
204: "No Content",
|
|
188
|
+
301: "Moved Permanently",
|
|
189
|
+
302: "Found",
|
|
190
|
+
304: "Not Modified",
|
|
191
|
+
400: "Bad Request",
|
|
192
|
+
401: "Unauthorized",
|
|
193
|
+
403: "Forbidden",
|
|
194
|
+
404: "Not Found",
|
|
195
|
+
405: "Method Not Allowed",
|
|
196
|
+
409: "Conflict",
|
|
197
|
+
422: "Unprocessable Entity",
|
|
198
|
+
429: "Too Many Requests",
|
|
199
|
+
500: "Internal Server Error",
|
|
200
|
+
502: "Bad Gateway",
|
|
201
|
+
503: "Service Unavailable"
|
|
202
|
+
};
|
|
203
|
+
function statusMessage(code) {
|
|
204
|
+
return HTTP_STATUS_MESSAGES[code] ?? "Unknown";
|
|
205
|
+
}
|
|
206
|
+
var RequestCollector = class {
|
|
207
|
+
constructor(masker = new Masker()) {
|
|
208
|
+
this.masker = masker;
|
|
209
|
+
}
|
|
210
|
+
masker;
|
|
211
|
+
data = null;
|
|
212
|
+
getName() {
|
|
213
|
+
return "request";
|
|
214
|
+
}
|
|
215
|
+
/** Parses the buffered response body as JSON when the response declared a JSON content-type — undefined otherwise (HTML pages, empty bodies, non-JSON payloads), so the detail view falls back to its "no body captured" state instead of dumping raw bytes. */
|
|
216
|
+
parseResponseBody(ctx) {
|
|
217
|
+
if (!ctx.responseBody || !ctx.responseBody.contentType.includes("application/json")) return void 0;
|
|
218
|
+
const text = ctx.responseBody.buffer.toString("utf8").trim();
|
|
219
|
+
if (!text) return void 0;
|
|
220
|
+
try {
|
|
221
|
+
return this.masker.maskDeep(JSON.parse(text));
|
|
222
|
+
} catch {
|
|
223
|
+
return void 0;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** Parses Set-Cookie response header(s) into name -> value, mirroring how request cookies are captured — masked per cookie name (Masker), not blanket-redacted like the raw "set-cookie" header. */
|
|
227
|
+
parseResponseCookies(res) {
|
|
228
|
+
const raw = res.getHeader("set-cookie");
|
|
229
|
+
const setCookies = Array.isArray(raw) ? raw.map(String) : raw ? [String(raw)] : [];
|
|
230
|
+
const cookies = {};
|
|
231
|
+
for (const setCookie of setCookies) {
|
|
232
|
+
const pair = setCookie.split(";")[0];
|
|
233
|
+
const eq = pair.indexOf("=");
|
|
234
|
+
if (eq === -1) continue;
|
|
235
|
+
const name = pair.slice(0, eq).trim();
|
|
236
|
+
if (!name) continue;
|
|
237
|
+
try {
|
|
238
|
+
cookies[name] = decodeURIComponent(pair.slice(eq + 1).trim());
|
|
239
|
+
} catch {
|
|
240
|
+
cookies[name] = pair.slice(eq + 1).trim();
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
return this.masker.maskShallow(cookies);
|
|
244
|
+
}
|
|
245
|
+
/**
|
|
246
|
+
* Reads `req.session` (express-session or any middleware that assigns a
|
|
247
|
+
* plain, JSON-serializable object there) — own data properties only,
|
|
248
|
+
* methods (save/destroy/reload/...) live on the prototype so a plain
|
|
249
|
+
* Object.entries() already excludes them. Returns undefined (not `{}`)
|
|
250
|
+
* when there's no session object at all, so the Session tab can tell
|
|
251
|
+
* "this app doesn't use sessions" apart from "session exists but empty".
|
|
252
|
+
*/
|
|
253
|
+
parseSession(req) {
|
|
254
|
+
const session = req.session;
|
|
255
|
+
if (!session || typeof session !== "object") return void 0;
|
|
256
|
+
const data = {};
|
|
257
|
+
for (const [key, value] of Object.entries(session)) {
|
|
258
|
+
if (typeof value === "function") continue;
|
|
259
|
+
data[key] = value;
|
|
260
|
+
}
|
|
261
|
+
return this.masker.maskDeep(data);
|
|
262
|
+
}
|
|
263
|
+
collect(ctx) {
|
|
264
|
+
const { req, res } = ctx;
|
|
265
|
+
const statusCode = res.statusCode;
|
|
266
|
+
this.data = {
|
|
267
|
+
method: req.method,
|
|
268
|
+
url: req.originalUrl || req.url,
|
|
269
|
+
statusCode,
|
|
270
|
+
statusMessage: statusMessage(statusCode),
|
|
271
|
+
request: {
|
|
272
|
+
method: req.method,
|
|
273
|
+
url: req.originalUrl || req.url,
|
|
274
|
+
headers: this.masker.maskShallow(req.headers),
|
|
275
|
+
query: this.masker.maskDeep(req.query ?? {}),
|
|
276
|
+
body: this.masker.maskDeep(req.body ?? {}),
|
|
277
|
+
params: req.params ?? {},
|
|
278
|
+
ip: req.ip ?? req.socket?.remoteAddress ?? "unknown",
|
|
279
|
+
cookies: this.masker.maskShallow(req.cookies ?? {}),
|
|
280
|
+
protocol: req.protocol,
|
|
281
|
+
hostname: req.hostname,
|
|
282
|
+
session: this.parseSession(req)
|
|
283
|
+
},
|
|
284
|
+
response: {
|
|
285
|
+
statusCode,
|
|
286
|
+
statusMessage: statusMessage(statusCode),
|
|
287
|
+
headers: this.masker.maskShallow(res.getHeaders()),
|
|
288
|
+
contentType: res.getHeader("content-type"),
|
|
289
|
+
cookies: this.parseResponseCookies(res),
|
|
290
|
+
body: this.parseResponseBody(ctx)
|
|
291
|
+
},
|
|
292
|
+
route: {
|
|
293
|
+
path: req.route?.path ?? req.url,
|
|
294
|
+
method: req.method,
|
|
295
|
+
params: req.params ?? {},
|
|
296
|
+
controller: req.route?.stack?.[0]?.name
|
|
297
|
+
}
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
getData() {
|
|
301
|
+
if (!this.data) {
|
|
302
|
+
throw new Error("RequestCollector.getData() called before collect()");
|
|
303
|
+
}
|
|
304
|
+
return this.data;
|
|
305
|
+
}
|
|
306
|
+
reset() {
|
|
307
|
+
this.data = null;
|
|
308
|
+
}
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
// src/collectors/time.collector.ts
|
|
312
|
+
var TimeCollector = class {
|
|
313
|
+
data = null;
|
|
314
|
+
phases = [];
|
|
315
|
+
getName() {
|
|
316
|
+
return "time";
|
|
317
|
+
}
|
|
318
|
+
/** Records a named checkpoint at the current elapsed time, relative to the active request's start. */
|
|
319
|
+
mark(name) {
|
|
320
|
+
const startHr = getActiveProfilingState()?.startHrTime;
|
|
321
|
+
if (!startHr) return;
|
|
322
|
+
const elapsedNs = process.hrtime.bigint() - startHr;
|
|
323
|
+
this.phases.push({ name, offset: Number(elapsedNs / 1000000n) });
|
|
324
|
+
}
|
|
325
|
+
collect(ctx) {
|
|
326
|
+
const durationNs = process.hrtime.bigint() - ctx.startHrTime;
|
|
327
|
+
this.data = {
|
|
328
|
+
startedAt: ctx.startTime,
|
|
329
|
+
duration: Number(durationNs / 1000000n),
|
|
330
|
+
phases: [...this.phases]
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
getData() {
|
|
334
|
+
if (!this.data) {
|
|
335
|
+
throw new Error("TimeCollector.getData() called before collect()");
|
|
336
|
+
}
|
|
337
|
+
return this.data;
|
|
338
|
+
}
|
|
339
|
+
reset() {
|
|
340
|
+
this.data = null;
|
|
341
|
+
this.phases.length = 0;
|
|
342
|
+
}
|
|
343
|
+
};
|
|
344
|
+
|
|
345
|
+
// src/collectors/memory.collector.ts
|
|
346
|
+
var MemoryCollector = class {
|
|
347
|
+
data = null;
|
|
348
|
+
getName() {
|
|
349
|
+
return "memory";
|
|
350
|
+
}
|
|
351
|
+
collect(ctx) {
|
|
352
|
+
const heapUsedAfter = process.memoryUsage().heapUsed;
|
|
353
|
+
this.data = {
|
|
354
|
+
heapUsedBefore: ctx.heapUsedBefore,
|
|
355
|
+
heapUsedAfter,
|
|
356
|
+
delta: heapUsedAfter - ctx.heapUsedBefore
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
getData() {
|
|
360
|
+
if (!this.data) {
|
|
361
|
+
throw new Error("MemoryCollector.getData() called before collect()");
|
|
362
|
+
}
|
|
363
|
+
return this.data;
|
|
364
|
+
}
|
|
365
|
+
reset() {
|
|
366
|
+
this.data = null;
|
|
367
|
+
}
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
// src/collectors/database.collector.ts
|
|
371
|
+
var DatabaseCollector = class {
|
|
372
|
+
queries = [];
|
|
373
|
+
getName() {
|
|
374
|
+
return "database";
|
|
375
|
+
}
|
|
376
|
+
record(query) {
|
|
377
|
+
this.queries.push({ ...query, timestamp: Date.now() });
|
|
378
|
+
}
|
|
379
|
+
collect(_ctx) {
|
|
380
|
+
}
|
|
381
|
+
getData() {
|
|
382
|
+
return [...this.queries];
|
|
383
|
+
}
|
|
384
|
+
reset() {
|
|
385
|
+
this.queries.length = 0;
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
|
|
389
|
+
// src/collectors/logger.collector.ts
|
|
390
|
+
var LoggerCollector = class {
|
|
391
|
+
logs = [];
|
|
392
|
+
getName() {
|
|
393
|
+
return "logger";
|
|
394
|
+
}
|
|
395
|
+
record(entry) {
|
|
396
|
+
this.logs.push({ ...entry, timestamp: Date.now() });
|
|
397
|
+
}
|
|
398
|
+
collect(_ctx) {
|
|
399
|
+
}
|
|
400
|
+
getData() {
|
|
401
|
+
return [...this.logs];
|
|
402
|
+
}
|
|
403
|
+
byLevel(level) {
|
|
404
|
+
return this.logs.filter((l) => l.level === level);
|
|
405
|
+
}
|
|
406
|
+
reset() {
|
|
407
|
+
this.logs.length = 0;
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
|
|
411
|
+
// src/collectors/exception.collector.ts
|
|
412
|
+
var ExceptionCollector = class {
|
|
413
|
+
error = null;
|
|
414
|
+
getName() {
|
|
415
|
+
return "exception";
|
|
416
|
+
}
|
|
417
|
+
setError(err) {
|
|
418
|
+
if (this.error) return;
|
|
419
|
+
this.error = {
|
|
420
|
+
name: err.name || "Error",
|
|
421
|
+
message: err.message,
|
|
422
|
+
stack: err.stack ?? ""
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
collect(_ctx, error) {
|
|
426
|
+
if (error) this.setError(error);
|
|
427
|
+
}
|
|
428
|
+
getData() {
|
|
429
|
+
return { error: this.error };
|
|
430
|
+
}
|
|
431
|
+
reset() {
|
|
432
|
+
this.error = null;
|
|
433
|
+
}
|
|
434
|
+
};
|
|
435
|
+
|
|
436
|
+
// src/collectors/index.ts
|
|
437
|
+
function createBuiltinCollectorFactories(security) {
|
|
438
|
+
const masker = new Masker(security.sensitiveKeys);
|
|
439
|
+
return [
|
|
440
|
+
() => new RequestCollector(masker),
|
|
441
|
+
() => new TimeCollector(),
|
|
442
|
+
() => new MemoryCollector(),
|
|
443
|
+
() => new DatabaseCollector(),
|
|
444
|
+
() => new LoggerCollector(),
|
|
445
|
+
() => new ExceptionCollector()
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// src/middleware/htmlInjector.ts
|
|
450
|
+
function installHtmlInjection(res, buildSnippet, onCapture) {
|
|
451
|
+
const chunks = [];
|
|
452
|
+
let intercepting = true;
|
|
453
|
+
const originalWrite = res.write.bind(res);
|
|
454
|
+
const originalEnd = res.end.bind(res);
|
|
455
|
+
function toBuffer(chunk, encoding) {
|
|
456
|
+
if (chunk === void 0 || chunk === null) return null;
|
|
457
|
+
if (Buffer.isBuffer(chunk)) return chunk;
|
|
458
|
+
const enc = typeof encoding === "string" ? encoding : "utf8";
|
|
459
|
+
return Buffer.from(chunk, enc);
|
|
460
|
+
}
|
|
461
|
+
res.write = function(chunk, ...rest) {
|
|
462
|
+
if (!intercepting) return originalWrite(chunk, ...rest);
|
|
463
|
+
const buf = toBuffer(chunk, rest[0]);
|
|
464
|
+
if (buf) chunks.push(buf);
|
|
465
|
+
return true;
|
|
466
|
+
};
|
|
467
|
+
res.end = function(chunk, ...rest) {
|
|
468
|
+
if (!intercepting) return originalEnd(chunk, ...rest);
|
|
469
|
+
intercepting = false;
|
|
470
|
+
const buf = toBuffer(chunk, rest[0]);
|
|
471
|
+
if (buf) chunks.push(buf);
|
|
472
|
+
const contentType = String(res.getHeader("content-type") ?? "");
|
|
473
|
+
const isHtml = contentType.includes("text/html");
|
|
474
|
+
let body = Buffer.concat(chunks);
|
|
475
|
+
if (isHtml && !res.headersSent) {
|
|
476
|
+
const text = body.toString("utf8");
|
|
477
|
+
if (/<\/body>/i.test(text)) {
|
|
478
|
+
const injected = text.replace(/<\/body>/i, `${buildSnippet()}
|
|
479
|
+
</body>`);
|
|
480
|
+
body = Buffer.from(injected, "utf8");
|
|
481
|
+
res.setHeader("Content-Length", Buffer.byteLength(body));
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
onCapture?.(body, contentType);
|
|
485
|
+
return originalEnd(body);
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
// src/utils/i18n.ts
|
|
490
|
+
var import_fs = require("fs");
|
|
491
|
+
var import_path = require("path");
|
|
492
|
+
|
|
493
|
+
// src/security/escape.ts
|
|
494
|
+
function escapeHtml(value) {
|
|
495
|
+
return String(value ?? "").replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
// src/utils/i18n.ts
|
|
499
|
+
var SUPPORTED_LANGUAGES = ["en", "fr"];
|
|
500
|
+
var DEFAULT_LANGUAGE = "en";
|
|
501
|
+
var LANG_COOKIE = "opticore_profiler_lang";
|
|
502
|
+
var LANG_LABELS = { en: "English", fr: "Fran\xE7ais" };
|
|
503
|
+
function isSupportedLanguage(value) {
|
|
504
|
+
return typeof value === "string" && SUPPORTED_LANGUAGES.includes(value);
|
|
505
|
+
}
|
|
506
|
+
var dictionaryCache = /* @__PURE__ */ new Map();
|
|
507
|
+
function loadDictionary(lang) {
|
|
508
|
+
const cached = dictionaryCache.get(lang);
|
|
509
|
+
if (cached) return cached;
|
|
510
|
+
const path = (0, import_path.join)(__dirname, "utils", "translations", `message.translation.${lang}.json`);
|
|
511
|
+
let dict = {};
|
|
512
|
+
try {
|
|
513
|
+
dict = JSON.parse((0, import_fs.readFileSync)(path, "utf8"));
|
|
514
|
+
} catch {
|
|
515
|
+
dict = {};
|
|
516
|
+
}
|
|
517
|
+
dictionaryCache.set(lang, dict);
|
|
518
|
+
return dict;
|
|
519
|
+
}
|
|
520
|
+
var SafeHtml = class {
|
|
521
|
+
constructor(value) {
|
|
522
|
+
this.value = value;
|
|
523
|
+
}
|
|
524
|
+
value;
|
|
525
|
+
};
|
|
526
|
+
function safeHtml(value) {
|
|
527
|
+
return new SafeHtml(value);
|
|
528
|
+
}
|
|
529
|
+
function createTranslator(lang) {
|
|
530
|
+
const dict = loadDictionary(lang);
|
|
531
|
+
const fallback = lang === DEFAULT_LANGUAGE ? dict : loadDictionary(DEFAULT_LANGUAGE);
|
|
532
|
+
return function t(key, vars) {
|
|
533
|
+
let str = dict[key] ?? fallback[key] ?? key;
|
|
534
|
+
if (vars) {
|
|
535
|
+
for (const [name, value] of Object.entries(vars)) {
|
|
536
|
+
const rendered = value instanceof SafeHtml ? value.value : escapeHtml(value);
|
|
537
|
+
str = str.split(`{${name}}`).join(rendered);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return str;
|
|
541
|
+
};
|
|
542
|
+
}
|
|
543
|
+
function parseCookie(header, name) {
|
|
544
|
+
if (!header) return void 0;
|
|
545
|
+
for (const part of header.split(";")) {
|
|
546
|
+
const eq = part.indexOf("=");
|
|
547
|
+
if (eq === -1) continue;
|
|
548
|
+
if (part.slice(0, eq).trim() === name) {
|
|
549
|
+
try {
|
|
550
|
+
return decodeURIComponent(part.slice(eq + 1).trim());
|
|
551
|
+
} catch {
|
|
552
|
+
return part.slice(eq + 1).trim();
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
return void 0;
|
|
557
|
+
}
|
|
558
|
+
function resolveLanguage(req, res) {
|
|
559
|
+
const queryLang = req.query?.lang;
|
|
560
|
+
if (isSupportedLanguage(queryLang)) {
|
|
561
|
+
res.setHeader("Set-Cookie", `${LANG_COOKIE}=${queryLang}; Path=/; Max-Age=31536000; SameSite=Lax`);
|
|
562
|
+
return queryLang;
|
|
563
|
+
}
|
|
564
|
+
const cookieLang = parseCookie(req.headers?.cookie, LANG_COOKIE);
|
|
565
|
+
if (isSupportedLanguage(cookieLang)) return cookieLang;
|
|
566
|
+
return DEFAULT_LANGUAGE;
|
|
567
|
+
}
|
|
568
|
+
function languageOptions(current) {
|
|
569
|
+
return SUPPORTED_LANGUAGES.map((code) => ({
|
|
570
|
+
code,
|
|
571
|
+
label: LANG_LABELS[code],
|
|
572
|
+
active: code === current
|
|
573
|
+
}));
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
// src/middleware/snippet.ts
|
|
577
|
+
function buildToolbarSnippet(token, routePrefix, lang) {
|
|
578
|
+
const t = createTranslator(lang);
|
|
579
|
+
const i18n = {
|
|
580
|
+
lastNRequests: t("toolbar.last_n_requests"),
|
|
581
|
+
viewAll: t("toolbar.view_all")
|
|
582
|
+
};
|
|
583
|
+
return `<link rel="stylesheet" href="${routePrefix}/assets/css/toolbar-standalone.css">
|
|
584
|
+
<div id="opticore-wdt-mount"></div>
|
|
585
|
+
<script>
|
|
586
|
+
(function () {
|
|
587
|
+
window.__opticoreProfilerToken = "${token}";
|
|
588
|
+
window.__opticoreProfilerRoutePrefix = "${routePrefix}";
|
|
589
|
+
window.__opticoreProfilerI18n = ${JSON.stringify(i18n)};
|
|
590
|
+
fetch("${routePrefix}/wdt/${token}", { credentials: "same-origin" })
|
|
591
|
+
.then(function (r) { return r.text(); })
|
|
592
|
+
.then(function (html) {
|
|
593
|
+
document.getElementById("opticore-wdt-mount").outerHTML = html;
|
|
594
|
+
let s = document.createElement("script");
|
|
595
|
+
s.src = "${routePrefix}/assets/js/toolbar.js";
|
|
596
|
+
document.body.appendChild(s);
|
|
597
|
+
})
|
|
598
|
+
.catch(function () {});
|
|
599
|
+
})();
|
|
600
|
+
</script>`;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// src/middleware/errorHandler.middleware.ts
|
|
604
|
+
function profilerErrorHandler() {
|
|
605
|
+
return function(err, _req, _res, next) {
|
|
606
|
+
getActiveCollector("exception")?.setError(err);
|
|
607
|
+
next(err);
|
|
608
|
+
};
|
|
609
|
+
}
|
|
610
|
+
var processHandlersRegistered = false;
|
|
611
|
+
function registerProcessErrorCapture() {
|
|
612
|
+
if (processHandlersRegistered) return;
|
|
613
|
+
processHandlersRegistered = true;
|
|
614
|
+
process.on("uncaughtExceptionMonitor", (err) => {
|
|
615
|
+
try {
|
|
616
|
+
getActiveCollector("exception")?.setError(err);
|
|
617
|
+
} catch {
|
|
618
|
+
}
|
|
619
|
+
});
|
|
620
|
+
process.on("unhandledRejection", (reason) => {
|
|
621
|
+
try {
|
|
622
|
+
const err = reason instanceof Error ? reason : new Error(String(reason));
|
|
623
|
+
getActiveCollector("exception")?.setError(err);
|
|
624
|
+
} catch {
|
|
625
|
+
}
|
|
626
|
+
});
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
// src/middleware/profiler.middleware.ts
|
|
630
|
+
var DEFAULT_ROUTE_PREFIX = "/_profiler";
|
|
631
|
+
var DEFAULT_MAX_REQUESTS = 10;
|
|
632
|
+
var DEFAULT_RETENTION_MS = 24 * 60 * 60 * 1e3;
|
|
633
|
+
function isUnderPrefix(path, prefix) {
|
|
634
|
+
return path === prefix || path.startsWith(`${prefix}/`);
|
|
635
|
+
}
|
|
636
|
+
function isExcludedPath(path, excludePaths) {
|
|
637
|
+
return excludePaths.some(
|
|
638
|
+
(pattern) => pattern.endsWith("/*") ? path.startsWith(pattern.slice(0, -1)) : path === pattern
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
function opticoreProfiler(options) {
|
|
642
|
+
const enabled = options.enabled ?? false;
|
|
643
|
+
const routePrefix = options.routePrefix ?? DEFAULT_ROUTE_PREFIX;
|
|
644
|
+
const storage = options.storage;
|
|
645
|
+
const retentionMs = options.retentionMs ?? DEFAULT_RETENTION_MS;
|
|
646
|
+
const maxRequests = options.maxRequests ?? DEFAULT_MAX_REQUESTS;
|
|
647
|
+
const excludePaths = options.excludePaths ?? [...DEFAULT_EXCLUDE_PATHS];
|
|
648
|
+
const security = {
|
|
649
|
+
sensitiveKeys: options.security?.sensitiveKeys ?? [...DEFAULT_SENSITIVE_KEYS],
|
|
650
|
+
maxUrlLength: options.security?.maxUrlLength ?? 100
|
|
651
|
+
};
|
|
652
|
+
const events = new import_events.EventEmitter();
|
|
653
|
+
events.setMaxListeners(0);
|
|
654
|
+
const registry = new CollectorRegistry();
|
|
655
|
+
if (options.collectors) {
|
|
656
|
+
for (const factory of options.collectors) registry.register(factory);
|
|
657
|
+
} else {
|
|
658
|
+
for (const factory of createBuiltinCollectorFactories(security)) registry.register(factory);
|
|
659
|
+
}
|
|
660
|
+
if (enabled) registerProcessErrorCapture();
|
|
661
|
+
const middleware = function opticoreProfilerMiddleware(req, res, next) {
|
|
662
|
+
if (!enabled || isUnderPrefix(req.path, routePrefix) || isExcludedPath(req.path, excludePaths)) {
|
|
663
|
+
next();
|
|
664
|
+
return;
|
|
665
|
+
}
|
|
666
|
+
const token = generateToken(8);
|
|
667
|
+
const startTime = Date.now();
|
|
668
|
+
const startHrTime = process.hrtime.bigint();
|
|
669
|
+
const heapUsedBefore = process.memoryUsage().heapUsed;
|
|
670
|
+
const collectors = registry.createAll();
|
|
671
|
+
res.setHeader("X-Debug-Token", token);
|
|
672
|
+
res.setHeader("X-Debug-Token-Link", `${routePrefix}/${token}`);
|
|
673
|
+
let responseBody;
|
|
674
|
+
const lang = resolveLanguage(req, res);
|
|
675
|
+
installHtmlInjection(res, () => buildToolbarSnippet(token, routePrefix, lang), (buffer, contentType) => {
|
|
676
|
+
responseBody = { buffer, contentType };
|
|
677
|
+
});
|
|
678
|
+
res.on("finish", () => {
|
|
679
|
+
void (async () => {
|
|
680
|
+
for (const collector of collectors.values()) {
|
|
681
|
+
try {
|
|
682
|
+
await collector.collect({ token, req, res, startTime, startHrTime, heapUsedBefore, responseBody });
|
|
683
|
+
} catch {
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
const requestData = collectors.get("request")?.getData();
|
|
687
|
+
const timeData = collectors.get("time")?.getData();
|
|
688
|
+
const profile = {
|
|
689
|
+
token,
|
|
690
|
+
ip: requestData?.request.ip ?? req.ip ?? "unknown",
|
|
691
|
+
method: requestData?.method ?? req.method,
|
|
692
|
+
url: requestData?.url ?? req.originalUrl ?? req.url,
|
|
693
|
+
statusCode: res.statusCode,
|
|
694
|
+
time: startTime,
|
|
695
|
+
duration: timeData?.duration ?? Date.now() - startTime,
|
|
696
|
+
contentType: res.getHeader("content-type"),
|
|
697
|
+
collectors: Object.fromEntries([...collectors].map(([name, c]) => [name, c.getData()]))
|
|
698
|
+
};
|
|
699
|
+
try {
|
|
700
|
+
await storage.write(profile);
|
|
701
|
+
events.emit("profile", profile);
|
|
702
|
+
if (retentionMs > 0) await storage.purge(retentionMs);
|
|
703
|
+
} catch {
|
|
704
|
+
}
|
|
705
|
+
})();
|
|
706
|
+
});
|
|
707
|
+
runWithProfilerContext({ token, collectors, startTime, startHrTime }, () => next());
|
|
708
|
+
};
|
|
709
|
+
middleware.registry = registry;
|
|
710
|
+
middleware.options = Object.freeze({ enabled, routePrefix, storage, retentionMs, maxRequests, excludePaths, security });
|
|
711
|
+
middleware.clear = () => storage.clear();
|
|
712
|
+
middleware.events = events;
|
|
713
|
+
return middleware;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// src/module.ts
|
|
717
|
+
var import_path3 = require("path");
|
|
718
|
+
var import_opticore_express = require("opticore-express");
|
|
719
|
+
var import_opticore_router = require("opticore-router");
|
|
720
|
+
|
|
721
|
+
// src/core/viewEngine.config.ts
|
|
722
|
+
var import_path2 = require("path");
|
|
723
|
+
var import_nunjucks = __toESM(require("nunjucks"), 1);
|
|
724
|
+
var configured = false;
|
|
725
|
+
function configureProfilerViewEngine(app) {
|
|
726
|
+
if (configured) return;
|
|
727
|
+
configured = true;
|
|
728
|
+
const viewsDir = (0, import_path2.join)(__dirname, "views", "templates");
|
|
729
|
+
import_nunjucks.default.configure(viewsDir, {
|
|
730
|
+
autoescape: true,
|
|
731
|
+
express: app,
|
|
732
|
+
noCache: process.env.NODE_ENV !== "production"
|
|
733
|
+
});
|
|
734
|
+
app.set("views", viewsDir);
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
// src/core/routeIntrospection.ts
|
|
738
|
+
var _expressApp = null;
|
|
739
|
+
function setIntrospectedApp(app) {
|
|
740
|
+
_expressApp = app;
|
|
741
|
+
}
|
|
742
|
+
function getLayerPrefix(layer) {
|
|
743
|
+
try {
|
|
744
|
+
const src = layer.regexp?.source ?? "";
|
|
745
|
+
if (!src) return "";
|
|
746
|
+
if (src === "^\\/?(?=\\/|$)" || src === "^\\/(?=\\/|$)" || src === "^\\/?$" || src === "^\\/$") return "";
|
|
747
|
+
let s = src.startsWith("^") ? src.slice(1) : src;
|
|
748
|
+
const markers = ["\\/?(?=", "(?=\\/", "\\/?$", "(?=$)"];
|
|
749
|
+
let endIdx = s.length;
|
|
750
|
+
for (const m of markers) {
|
|
751
|
+
const i = s.indexOf(m);
|
|
752
|
+
if (i !== -1 && i < endIdx) endIdx = i;
|
|
753
|
+
}
|
|
754
|
+
s = s.slice(0, endIdx);
|
|
755
|
+
s = s.replace(/\\\//g, "/").replace(/\\\./g, ".");
|
|
756
|
+
if (s.endsWith("/?")) s = s.slice(0, -2);
|
|
757
|
+
return s && s !== "/" ? s : "";
|
|
758
|
+
} catch {
|
|
759
|
+
return "";
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
function extractRoutesFromApp(app) {
|
|
763
|
+
const result = [];
|
|
764
|
+
function traverse(stack, prefix) {
|
|
765
|
+
for (const layer of stack ?? []) {
|
|
766
|
+
if (!layer) continue;
|
|
767
|
+
if (layer.route) {
|
|
768
|
+
const methods = Object.keys(layer.route.methods ?? {}).filter((m) => layer.route.methods[m]).map((m) => m.toUpperCase());
|
|
769
|
+
const routePath = String(layer.route.path ?? "");
|
|
770
|
+
const fullPath = (prefix + routePath).replace(/\/+/g, "/") || "/";
|
|
771
|
+
for (const method of methods) {
|
|
772
|
+
result.push({
|
|
773
|
+
method,
|
|
774
|
+
path: fullPath,
|
|
775
|
+
middlewareCount: layer.route.stack?.length ?? 1
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
} else if (layer.handle && typeof layer.handle === "function" && layer.handle.stack) {
|
|
779
|
+
const seg = getLayerPrefix(layer);
|
|
780
|
+
traverse(layer.handle.stack, prefix + seg);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const router = app._router ?? app.router;
|
|
785
|
+
traverse(router?.stack ?? [], "");
|
|
786
|
+
return result;
|
|
787
|
+
}
|
|
788
|
+
function getExpressRoutes() {
|
|
789
|
+
if (!_expressApp) return [];
|
|
790
|
+
return extractRoutesFromApp(_expressApp);
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
// src/controllers/profiler.controller.ts
|
|
794
|
+
var import_child_process = require("child_process");
|
|
795
|
+
|
|
796
|
+
// src/view/legacyAdapter.ts
|
|
797
|
+
function toLegacyProfile(profile) {
|
|
798
|
+
const requestData = profile.collectors.request;
|
|
799
|
+
const timeData = profile.collectors.time;
|
|
800
|
+
const memoryData = profile.collectors.memory;
|
|
801
|
+
const queries = profile.collectors.database ?? [];
|
|
802
|
+
const logs = [...profile.collectors.logger ?? []];
|
|
803
|
+
const exceptionData = profile.collectors.exception;
|
|
804
|
+
if (exceptionData?.error) {
|
|
805
|
+
logs.push({
|
|
806
|
+
level: "critical",
|
|
807
|
+
message: `${exceptionData.error.name}: ${exceptionData.error.message}`,
|
|
808
|
+
timestamp: profile.time,
|
|
809
|
+
context: { stack: exceptionData.error.stack }
|
|
810
|
+
});
|
|
811
|
+
}
|
|
812
|
+
return {
|
|
813
|
+
token: profile.token,
|
|
814
|
+
timestamp: profile.time,
|
|
815
|
+
method: requestData?.method ?? profile.method,
|
|
816
|
+
url: requestData?.url ?? profile.url,
|
|
817
|
+
statusCode: requestData?.statusCode ?? profile.statusCode,
|
|
818
|
+
statusMessage: requestData?.statusMessage ?? "",
|
|
819
|
+
duration: timeData?.duration ?? profile.duration,
|
|
820
|
+
memoryUsage: memoryData?.heapUsedAfter ?? 0,
|
|
821
|
+
queries,
|
|
822
|
+
logs,
|
|
823
|
+
route: requestData?.route ?? { path: profile.url, method: profile.method, params: {} },
|
|
824
|
+
request: requestData?.request ?? {
|
|
825
|
+
method: profile.method,
|
|
826
|
+
url: profile.url,
|
|
827
|
+
headers: {},
|
|
828
|
+
query: {},
|
|
829
|
+
body: {},
|
|
830
|
+
params: {},
|
|
831
|
+
ip: profile.ip
|
|
832
|
+
},
|
|
833
|
+
response: requestData?.response ?? { statusCode: profile.statusCode, statusMessage: "", headers: {}, contentType: profile.contentType },
|
|
834
|
+
performance: [],
|
|
835
|
+
nodeVersion: process.version,
|
|
836
|
+
appVersion: process.env.npm_package_version ?? "1.0.0",
|
|
837
|
+
environment: process.env.NODE_ENV ?? "development"
|
|
838
|
+
};
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
// src/core/render.util.ts
|
|
842
|
+
function renderView(req, res, view, data = {}) {
|
|
843
|
+
const lang = resolveLanguage(req, res);
|
|
844
|
+
const t = createTranslator(lang);
|
|
845
|
+
const fullData = { ...data, t, lang, languages: languageOptions(lang), safeHtml };
|
|
846
|
+
return new Promise((resolve, reject) => {
|
|
847
|
+
res.render(view, fullData, (err, html) => {
|
|
848
|
+
if (err) {
|
|
849
|
+
reject(err);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
res.setHeader("Cache-Control", "no-store");
|
|
853
|
+
res.send(html);
|
|
854
|
+
resolve();
|
|
855
|
+
});
|
|
856
|
+
});
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
// src/view/helpers.view.ts
|
|
860
|
+
function statusClass(code) {
|
|
861
|
+
if (code >= 500) return "s-error";
|
|
862
|
+
if (code >= 400) return "s-warn";
|
|
863
|
+
if (code >= 300) return "s-redirect";
|
|
864
|
+
return "s-ok";
|
|
865
|
+
}
|
|
866
|
+
function formatDuration(ms) {
|
|
867
|
+
return ms < 1e3 ? `${ms} ms` : `${(ms / 1e3).toFixed(2)} s`;
|
|
868
|
+
}
|
|
869
|
+
function formatMemory(bytes) {
|
|
870
|
+
return `${(Math.max(0, bytes) / 1024 / 1024).toFixed(1)} MiB`;
|
|
871
|
+
}
|
|
872
|
+
function sqlTotalTime(profile) {
|
|
873
|
+
return profile.queries.reduce((sum, q) => sum + q.duration, 0);
|
|
874
|
+
}
|
|
875
|
+
function highlightJson(value) {
|
|
876
|
+
const pretty = JSON.stringify(value, null, 2);
|
|
877
|
+
return pretty.split("\n").map((line) => {
|
|
878
|
+
const m = line.match(/^(\s*)"((?:[^"\\]|\\.)*)":\s*(.*)$/);
|
|
879
|
+
if (!m) return escapeHtml(line);
|
|
880
|
+
const [, indent, key, rest] = m;
|
|
881
|
+
return `${indent}<span class="json-key">"${escapeHtml(key)}"</span>: <span class="json-val">${escapeHtml(rest)}</span>`;
|
|
882
|
+
}).join("\n");
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// src/view/toolbar.view.ts
|
|
886
|
+
function computeMetrics(profile, httpCount) {
|
|
887
|
+
return {
|
|
888
|
+
sqlTotalTime: sqlTotalTime(profile),
|
|
889
|
+
sqlCount: profile.queries.length,
|
|
890
|
+
logCount: profile.logs.length,
|
|
891
|
+
logErrors: profile.logs.filter((l) => l.level === "error" || l.level === "critical").length,
|
|
892
|
+
logWarnings: profile.logs.filter((l) => l.level === "warning").length,
|
|
893
|
+
logDeprecations: profile.logs.filter((l) => l.level === "deprecation").length,
|
|
894
|
+
httpCount,
|
|
895
|
+
memoryFormatted: formatMemory(profile.memoryUsage),
|
|
896
|
+
durationFormatted: formatDuration(profile.duration),
|
|
897
|
+
statusClass: statusClass(profile.statusCode)
|
|
898
|
+
};
|
|
899
|
+
}
|
|
900
|
+
function truncateUrl(url, maxLen) {
|
|
901
|
+
if (url.length <= maxLen) return url;
|
|
902
|
+
return url.slice(0, maxLen - 1) + "\u2026";
|
|
903
|
+
}
|
|
904
|
+
function isApiRequest(profile) {
|
|
905
|
+
return !(profile.response.contentType ?? "").toLowerCase().startsWith("text/html");
|
|
906
|
+
}
|
|
907
|
+
function buildToolbarBarViewModel(profile, recentProfiles, security, routePrefix, maxRequests) {
|
|
908
|
+
const apiProfiles = recentProfiles.filter(isApiRequest);
|
|
909
|
+
const metrics = computeMetrics(profile, apiProfiles.length);
|
|
910
|
+
const base = `${routePrefix}/${profile.token}`;
|
|
911
|
+
const httpRows = apiProfiles.slice(0, maxRequests).map((p) => ({
|
|
912
|
+
isCurrent: p.token === profile.token,
|
|
913
|
+
method: p.method,
|
|
914
|
+
url: truncateUrl(p.url, security.maxUrlLength),
|
|
915
|
+
fullUrl: p.url,
|
|
916
|
+
token: p.token,
|
|
917
|
+
statusCode: p.statusCode,
|
|
918
|
+
statusClass: p.statusCode >= 400 ? "tip-s-err" : p.statusCode >= 300 ? "tip-s-redir" : "tip-s-ok",
|
|
919
|
+
duration: formatDuration(p.duration)
|
|
920
|
+
}));
|
|
921
|
+
return {
|
|
922
|
+
statusCode: profile.statusCode,
|
|
923
|
+
statusMessage: profile.statusMessage,
|
|
924
|
+
profilerUrl: base,
|
|
925
|
+
statusCls: metrics.statusClass,
|
|
926
|
+
appVersion: profile.appVersion,
|
|
927
|
+
nodeVersion: profile.nodeVersion,
|
|
928
|
+
environment: profile.environment,
|
|
929
|
+
method: profile.method,
|
|
930
|
+
url: profile.url,
|
|
931
|
+
token: profile.token,
|
|
932
|
+
base,
|
|
933
|
+
duration: {
|
|
934
|
+
formatted: metrics.durationFormatted,
|
|
935
|
+
barWidth: Math.min(metrics.sqlTotalTime / 2, 100),
|
|
936
|
+
appFormatted: metrics.durationFormatted,
|
|
937
|
+
dbFormatted: metrics.sqlCount > 0 ? formatDuration(metrics.sqlTotalTime) : "0 ms"
|
|
938
|
+
},
|
|
939
|
+
memory: { formatted: metrics.memoryFormatted },
|
|
940
|
+
logs: { count: metrics.logCount, errors: metrics.logErrors, warnings: metrics.logWarnings, deprecations: metrics.logDeprecations },
|
|
941
|
+
route: { label: profile.route.path || profile.url, method: profile.route.method || profile.method },
|
|
942
|
+
http: { count: metrics.httpCount, rows: httpRows }
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// src/view/profilerList.view.ts
|
|
947
|
+
function statusClassOf(code) {
|
|
948
|
+
if (code >= 400) return "error";
|
|
949
|
+
if (code >= 300) return "redirect";
|
|
950
|
+
return "ok";
|
|
951
|
+
}
|
|
952
|
+
function fmtDate(ts) {
|
|
953
|
+
return new Date(ts).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" });
|
|
954
|
+
}
|
|
955
|
+
function fmtClock(ts) {
|
|
956
|
+
return new Date(ts).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
|
957
|
+
}
|
|
958
|
+
function toProfilerListRow(p) {
|
|
959
|
+
return {
|
|
960
|
+
token: p.token,
|
|
961
|
+
tokenShort: p.token.slice(0, 6),
|
|
962
|
+
statusCode: p.statusCode,
|
|
963
|
+
statusClass: statusClassOf(p.statusCode),
|
|
964
|
+
method: p.method,
|
|
965
|
+
url: p.url,
|
|
966
|
+
dateStr: fmtDate(p.timestamp),
|
|
967
|
+
timeStr: fmtClock(p.timestamp),
|
|
968
|
+
duration: p.duration,
|
|
969
|
+
contentType: p.response.contentType
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
function tabHref(tab, search, method, status, ip, token, from, until, limit) {
|
|
973
|
+
const params = new URLSearchParams();
|
|
974
|
+
if (search) params.set("search", search);
|
|
975
|
+
if (method) params.set("method", method);
|
|
976
|
+
if (status) params.set("status", status);
|
|
977
|
+
if (ip) params.set("ip", ip);
|
|
978
|
+
if (token) params.set("token", token);
|
|
979
|
+
if (from) params.set("from", from);
|
|
980
|
+
if (until) params.set("until", until);
|
|
981
|
+
if (limit !== 10) params.set("limit", String(limit));
|
|
982
|
+
if (tab !== "requests") params.set("tab", tab);
|
|
983
|
+
const qs = params.toString();
|
|
984
|
+
return `/_profiler${qs ? "?" + qs : ""}`;
|
|
985
|
+
}
|
|
986
|
+
function buildProfilerListViewModel(profiles, search = "", method = "", status = "", limit = 10, ip = "", token = "", from = "", until = "", tab = "requests") {
|
|
987
|
+
let filtered = [...profiles];
|
|
988
|
+
if (ip) {
|
|
989
|
+
filtered = filtered.filter((p) => (p.request?.ip ?? "").toLowerCase().includes(ip.toLowerCase()));
|
|
990
|
+
}
|
|
991
|
+
if (token) {
|
|
992
|
+
filtered = filtered.filter((p) => p.token.toLowerCase().startsWith(token.toLowerCase()));
|
|
993
|
+
}
|
|
994
|
+
if (search) {
|
|
995
|
+
const q = search.toLowerCase();
|
|
996
|
+
filtered = filtered.filter(
|
|
997
|
+
(p) => p.url.toLowerCase().includes(q) || p.method.toLowerCase().includes(q) || p.token.toLowerCase().includes(q) || (p.request?.ip ?? "").toLowerCase().includes(q) || String(p.statusCode).includes(q)
|
|
998
|
+
);
|
|
999
|
+
}
|
|
1000
|
+
if (method) {
|
|
1001
|
+
filtered = filtered.filter((p) => p.method === method.toUpperCase());
|
|
1002
|
+
}
|
|
1003
|
+
if (status) {
|
|
1004
|
+
const code = parseInt(status, 10);
|
|
1005
|
+
if (code >= 100) {
|
|
1006
|
+
filtered = filtered.filter((p) => p.statusCode === code);
|
|
1007
|
+
} else if (code >= 1 && code <= 9) {
|
|
1008
|
+
const prefix = code * 100;
|
|
1009
|
+
filtered = filtered.filter((p) => p.statusCode >= prefix && p.statusCode < prefix + 100);
|
|
1010
|
+
}
|
|
1011
|
+
}
|
|
1012
|
+
if (from) {
|
|
1013
|
+
const ts = new Date(from).getTime();
|
|
1014
|
+
if (!isNaN(ts)) filtered = filtered.filter((p) => p.timestamp >= ts);
|
|
1015
|
+
}
|
|
1016
|
+
if (until) {
|
|
1017
|
+
const ts = new Date(until).getTime() + 86399999;
|
|
1018
|
+
if (!isNaN(ts)) filtered = filtered.filter((p) => p.timestamp <= ts);
|
|
1019
|
+
}
|
|
1020
|
+
filtered = filtered.slice(0, limit);
|
|
1021
|
+
const isFiltered = !!(search || method || status || ip || token || from || until);
|
|
1022
|
+
const rows = filtered.map(toProfilerListRow);
|
|
1023
|
+
return {
|
|
1024
|
+
tab,
|
|
1025
|
+
resultCount: filtered.length,
|
|
1026
|
+
rows,
|
|
1027
|
+
autoReload: !isFiltered && tab === "requests",
|
|
1028
|
+
filters: {
|
|
1029
|
+
ip,
|
|
1030
|
+
status,
|
|
1031
|
+
search,
|
|
1032
|
+
token,
|
|
1033
|
+
from,
|
|
1034
|
+
until,
|
|
1035
|
+
hasProfiles: profiles.length > 0,
|
|
1036
|
+
methodOptions: ["Any", "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"].map((m) => {
|
|
1037
|
+
const value = m === "Any" ? "" : m;
|
|
1038
|
+
return { value, label: m, selected: method === value };
|
|
1039
|
+
}),
|
|
1040
|
+
limitOptions: [10, 25, 50, 100].map((n) => ({ value: n, selected: limit === n }))
|
|
1041
|
+
},
|
|
1042
|
+
tabs: {
|
|
1043
|
+
requests: { href: tabHref("requests", search, method, status, ip, token, from, until, limit), active: tab === "requests" },
|
|
1044
|
+
commands: { href: tabHref("commands", search, method, status, ip, token, from, until, limit), active: tab === "commands" }
|
|
1045
|
+
}
|
|
1046
|
+
};
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// src/view/profilerDetail.view.ts
|
|
1050
|
+
function toKvRows(obj) {
|
|
1051
|
+
return Object.entries(obj).map(([key, v]) => ({
|
|
1052
|
+
key,
|
|
1053
|
+
value: typeof v === "object" && v !== null ? JSON.stringify(v, null, 2) : String(v)
|
|
1054
|
+
}));
|
|
1055
|
+
}
|
|
1056
|
+
function headersToRows(headers) {
|
|
1057
|
+
return Object.entries(headers ?? {}).map(([key, value]) => ({ key, value }));
|
|
1058
|
+
}
|
|
1059
|
+
function buildCurlCommand(profile) {
|
|
1060
|
+
const headers = profile.request.headers ?? {};
|
|
1061
|
+
const host = headers.host ?? profile.request.hostname ?? "localhost";
|
|
1062
|
+
const protocol = profile.request.protocol ?? "http";
|
|
1063
|
+
const url = `${protocol}://${host}${profile.url}`;
|
|
1064
|
+
const lines = ["curl \\", " --compressed \\", ` --url '${url}' \\`];
|
|
1065
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
1066
|
+
const k = name.toLowerCase();
|
|
1067
|
+
if (k === "host" || k === "content-length" || k === "cookie") continue;
|
|
1068
|
+
lines.push(` --header '${name}: ${value}' \\`);
|
|
1069
|
+
}
|
|
1070
|
+
if (headers.cookie) lines.push(` --cookie '${headers.cookie}' \\`);
|
|
1071
|
+
const body = profile.request.body ?? {};
|
|
1072
|
+
if (Object.keys(body).length > 0) {
|
|
1073
|
+
lines.push(` --data '${JSON.stringify(body)}' \\`);
|
|
1074
|
+
}
|
|
1075
|
+
const last = lines.pop();
|
|
1076
|
+
lines.push(last.replace(/ \\$/, ""));
|
|
1077
|
+
return lines.join("\n");
|
|
1078
|
+
}
|
|
1079
|
+
function buildRequestPanel(profile) {
|
|
1080
|
+
const cookies = profile.request.cookies ?? {};
|
|
1081
|
+
const responseCookies = profile.response.cookies ?? {};
|
|
1082
|
+
const body = profile.request.body ?? {};
|
|
1083
|
+
const hasBody = Object.keys(body).length > 0;
|
|
1084
|
+
const headers = profile.request.headers ?? {};
|
|
1085
|
+
return {
|
|
1086
|
+
statusOk: profile.statusCode < 400,
|
|
1087
|
+
statusCode: profile.statusCode,
|
|
1088
|
+
durationFormatted: formatDuration(profile.duration),
|
|
1089
|
+
memoryFormatted: formatMemory(profile.memoryUsage),
|
|
1090
|
+
sqlQueryCount: profile.queries.length,
|
|
1091
|
+
getParams: toKvRows(profile.request.query ?? {}),
|
|
1092
|
+
postParamsJson: hasBody ? highlightJson(body) : null,
|
|
1093
|
+
uploadedFiles: [],
|
|
1094
|
+
routeParams: toKvRows(profile.request.params ?? {}),
|
|
1095
|
+
attributes: [
|
|
1096
|
+
{ key: "_method", value: profile.method },
|
|
1097
|
+
{ key: "_route", value: profile.route.path },
|
|
1098
|
+
{ key: "_controller", value: profile.route.controller ?? "unknown" },
|
|
1099
|
+
{ key: "_ip", value: profile.request.ip },
|
|
1100
|
+
{ key: "_hostname", value: profile.request.hostname ?? "localhost" },
|
|
1101
|
+
{ key: "_protocol", value: profile.request.protocol ?? "http" }
|
|
1102
|
+
],
|
|
1103
|
+
requestHeaders: headersToRows(headers),
|
|
1104
|
+
responseHeaders: headersToRows(profile.response.headers),
|
|
1105
|
+
hasCookies: Object.keys(cookies).length > 0,
|
|
1106
|
+
cookies: toKvRows(cookies),
|
|
1107
|
+
hasResponseCookies: Object.keys(responseCookies).length > 0,
|
|
1108
|
+
responseCookies: toKvRows(responseCookies),
|
|
1109
|
+
requestContentJson: hasBody ? highlightJson(body) : null,
|
|
1110
|
+
responseBodyJson: profile.response.body ? highlightJson(profile.response.body) : null,
|
|
1111
|
+
retryCommandCurl: buildCurlCommand(profile),
|
|
1112
|
+
serverParams: [
|
|
1113
|
+
{ key: "HTTP_HOST", value: headers.host ?? profile.request.hostname ?? "localhost" },
|
|
1114
|
+
{ key: "HTTP_USER_AGENT", value: headers["user-agent"] ?? "" },
|
|
1115
|
+
{ key: "REQUEST_METHOD", value: profile.method },
|
|
1116
|
+
{ key: "REQUEST_URI", value: profile.url },
|
|
1117
|
+
{ key: "SERVER_PROTOCOL", value: (profile.request.protocol ?? "http").toUpperCase() },
|
|
1118
|
+
{ key: "REMOTE_ADDR", value: profile.request.ip },
|
|
1119
|
+
{ key: "NODE_ENV", value: profile.environment },
|
|
1120
|
+
{ key: "NODE_VERSION", value: profile.nodeVersion },
|
|
1121
|
+
{ key: "APP_VERSION", value: profile.appVersion }
|
|
1122
|
+
],
|
|
1123
|
+
hasSession: profile.request.session !== void 0,
|
|
1124
|
+
session: toKvRows(profile.request.session ?? {})
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
function buildPerformancePanel(profile) {
|
|
1128
|
+
const total = profile.duration;
|
|
1129
|
+
const sqlTime = sqlTotalTime(profile);
|
|
1130
|
+
const appTime = Math.max(0, total - sqlTime);
|
|
1131
|
+
const rawEvents = [
|
|
1132
|
+
{ label: "kernel.request (Application)", duration: appTime, color: "#C87A3C" },
|
|
1133
|
+
...sqlTime > 0 ? [{ label: "kernel.response (Database SQL)", duration: sqlTime, color: "#2D6A4A" }] : []
|
|
1134
|
+
].filter((e) => e.duration > 0);
|
|
1135
|
+
const events = rawEvents.map((e) => {
|
|
1136
|
+
const pct = total > 0 ? Math.min(e.duration / total * 100, 100) : 0;
|
|
1137
|
+
return {
|
|
1138
|
+
label: e.label,
|
|
1139
|
+
widthPct: Math.max(pct, 0.5).toFixed(1),
|
|
1140
|
+
color: e.color,
|
|
1141
|
+
durationFormatted: formatDuration(e.duration)
|
|
1142
|
+
};
|
|
1143
|
+
});
|
|
1144
|
+
return {
|
|
1145
|
+
total,
|
|
1146
|
+
appTime,
|
|
1147
|
+
memoryMiB: (Math.max(0, profile.memoryUsage) / 1024 / 1024).toFixed(2),
|
|
1148
|
+
hasEvents: events.length > 0,
|
|
1149
|
+
events,
|
|
1150
|
+
hasSqlTime: sqlTime > 0,
|
|
1151
|
+
processInfo: [
|
|
1152
|
+
{ key: "Node.js version", value: profile.nodeVersion },
|
|
1153
|
+
{ key: "App version", value: profile.appVersion },
|
|
1154
|
+
{ key: "Environment", badge: { text: profile.environment, variant: profile.environment === "production" ? "warn" : "info" } },
|
|
1155
|
+
{ key: "Process PID", value: String(process.pid) },
|
|
1156
|
+
{ key: "Platform", value: process.platform },
|
|
1157
|
+
{ key: "Heap used", value: formatMemory(process.memoryUsage().heapUsed) },
|
|
1158
|
+
{ key: "Heap total", value: formatMemory(process.memoryUsage().heapTotal) },
|
|
1159
|
+
{ key: "RSS", value: formatMemory(process.memoryUsage().rss) },
|
|
1160
|
+
{ key: "Uptime", value: `${Math.round(process.uptime())}s` }
|
|
1161
|
+
]
|
|
1162
|
+
};
|
|
1163
|
+
}
|
|
1164
|
+
function sqlType(sql) {
|
|
1165
|
+
const t = sql.trimStart().toUpperCase().slice(0, 6);
|
|
1166
|
+
if (t.startsWith("SELECT")) return "SELECT";
|
|
1167
|
+
if (t.startsWith("INSERT")) return "INSERT";
|
|
1168
|
+
if (t.startsWith("UPDATE")) return "UPDATE";
|
|
1169
|
+
if (t.startsWith("DELETE")) return "DELETE";
|
|
1170
|
+
return "OTHER";
|
|
1171
|
+
}
|
|
1172
|
+
function buildDatabasePanel(profile) {
|
|
1173
|
+
const queries = profile.queries;
|
|
1174
|
+
const totalTime = sqlTotalTime(profile);
|
|
1175
|
+
return {
|
|
1176
|
+
hasQueries: queries.length > 0,
|
|
1177
|
+
totalCount: queries.length,
|
|
1178
|
+
totalTimeFormatted: formatDuration(totalTime),
|
|
1179
|
+
avgFormatted: queries.length > 0 ? formatDuration(Math.round(totalTime / queries.length)) : "0 ms",
|
|
1180
|
+
queries: queries.map((q, i) => ({
|
|
1181
|
+
type: sqlType(q.sql),
|
|
1182
|
+
index: i + 1,
|
|
1183
|
+
hasError: !!q.error,
|
|
1184
|
+
durationFormatted: formatDuration(q.duration),
|
|
1185
|
+
sql: q.sql,
|
|
1186
|
+
bindingsJson: q.bindings && q.bindings.length > 0 ? JSON.stringify(q.bindings) : null,
|
|
1187
|
+
error: q.error ?? null
|
|
1188
|
+
}))
|
|
1189
|
+
};
|
|
1190
|
+
}
|
|
1191
|
+
function buildLogsPanel(profile) {
|
|
1192
|
+
const logs = profile.logs;
|
|
1193
|
+
if (logs.length === 0) {
|
|
1194
|
+
return { hasLogs: false, errCount: 0, warnCount: 0, deprCount: 0, rows: [] };
|
|
1195
|
+
}
|
|
1196
|
+
const errCount = logs.filter((l) => l.level === "error" || l.level === "critical").length;
|
|
1197
|
+
const warnCount = logs.filter((l) => l.level === "warning").length;
|
|
1198
|
+
const deprCount = logs.filter((l) => l.level === "deprecation").length;
|
|
1199
|
+
function levelCls(level) {
|
|
1200
|
+
if (level === "critical") return "log-error";
|
|
1201
|
+
if (level === "deprecation") return "log-deprecation";
|
|
1202
|
+
return `log-${level}`;
|
|
1203
|
+
}
|
|
1204
|
+
function rowBorderCls(level) {
|
|
1205
|
+
if (level === "error" || level === "critical") return "log-row-error";
|
|
1206
|
+
if (level === "warning") return "log-row-warning";
|
|
1207
|
+
if (level === "deprecation") return "log-row-deprecation";
|
|
1208
|
+
return "";
|
|
1209
|
+
}
|
|
1210
|
+
function logType(level) {
|
|
1211
|
+
if (level === "error" || level === "critical") return "error";
|
|
1212
|
+
if (level === "warning") return "warning";
|
|
1213
|
+
if (level === "deprecation") return "deprecation";
|
|
1214
|
+
return "other";
|
|
1215
|
+
}
|
|
1216
|
+
const rows = logs.map((l, i) => {
|
|
1217
|
+
const ctx = l.context ?? {};
|
|
1218
|
+
const hasCtx = Object.keys(ctx).length > 0;
|
|
1219
|
+
const hasStack = hasCtx && typeof ctx.stack === "string" && ctx.stack.length > 0;
|
|
1220
|
+
const source = hasCtx && typeof ctx.source === "string" ? ctx.source : "";
|
|
1221
|
+
const ctxId = `lctx${i}`;
|
|
1222
|
+
const stackId = `lstk${i}`;
|
|
1223
|
+
const ms = String(l.timestamp % 1e3).padStart(3, "0");
|
|
1224
|
+
const timeStr = new Date(l.timestamp).toLocaleTimeString("en-US", {
|
|
1225
|
+
hour: "2-digit",
|
|
1226
|
+
minute: "2-digit",
|
|
1227
|
+
second: "2-digit",
|
|
1228
|
+
hour12: true
|
|
1229
|
+
}) + "." + ms;
|
|
1230
|
+
let extra = null;
|
|
1231
|
+
if (hasStack) {
|
|
1232
|
+
const stack = ctx.stack;
|
|
1233
|
+
const ctxWithoutStack = {};
|
|
1234
|
+
for (const [k, v] of Object.entries(ctx)) {
|
|
1235
|
+
if (k !== "stack") ctxWithoutStack[k] = v;
|
|
1236
|
+
}
|
|
1237
|
+
const hasOtherCtx = Object.keys(ctxWithoutStack).length > 0;
|
|
1238
|
+
extra = {
|
|
1239
|
+
showContextBtn: hasOtherCtx,
|
|
1240
|
+
showTraceBtn: true,
|
|
1241
|
+
ctxId,
|
|
1242
|
+
stackId,
|
|
1243
|
+
ctxJson: hasOtherCtx ? JSON.stringify(ctxWithoutStack, null, 2) : null,
|
|
1244
|
+
stackText: stack
|
|
1245
|
+
};
|
|
1246
|
+
} else if (hasCtx) {
|
|
1247
|
+
extra = {
|
|
1248
|
+
showContextBtn: true,
|
|
1249
|
+
showTraceBtn: false,
|
|
1250
|
+
ctxId,
|
|
1251
|
+
stackId,
|
|
1252
|
+
ctxJson: JSON.stringify(ctx, null, 2),
|
|
1253
|
+
stackText: null
|
|
1254
|
+
};
|
|
1255
|
+
}
|
|
1256
|
+
return {
|
|
1257
|
+
rowClass: rowBorderCls(l.level),
|
|
1258
|
+
logType: logType(l.level),
|
|
1259
|
+
timeStr,
|
|
1260
|
+
levelCls: levelCls(l.level),
|
|
1261
|
+
level: l.level,
|
|
1262
|
+
message: l.message,
|
|
1263
|
+
source,
|
|
1264
|
+
extra
|
|
1265
|
+
};
|
|
1266
|
+
});
|
|
1267
|
+
return { hasLogs: true, errCount, warnCount, deprCount, rows };
|
|
1268
|
+
}
|
|
1269
|
+
function buildRoutingPanel(profile) {
|
|
1270
|
+
const routeParams = profile.route.params ?? {};
|
|
1271
|
+
const queryParams = profile.request.query ?? {};
|
|
1272
|
+
return {
|
|
1273
|
+
routeRows: [
|
|
1274
|
+
{ key: "Route", value: profile.route.path, code: true },
|
|
1275
|
+
{ key: "Method", badge: { text: profile.route.method, variant: "info" } },
|
|
1276
|
+
{ key: "Controller", value: profile.route.controller ?? "unknown" },
|
|
1277
|
+
{ key: "Full URL", value: profile.request.url, code: true }
|
|
1278
|
+
],
|
|
1279
|
+
hasRouteParams: Object.keys(routeParams).length > 0,
|
|
1280
|
+
routeParamsRows: Object.entries(routeParams).map(([key, v]) => ({ key, value: String(v), code: true })),
|
|
1281
|
+
hasQueryParams: Object.keys(queryParams).length > 0,
|
|
1282
|
+
queryParamsRows: Object.entries(queryParams).map(([key, v]) => ({ key, value: String(v), code: true }))
|
|
1283
|
+
};
|
|
1284
|
+
}
|
|
1285
|
+
function buildConfigurationPanel(profile) {
|
|
1286
|
+
const safeEnv = Object.entries(process.env).filter(([k]) => !/(PASSWORD|SECRET|KEY|TOKEN|AUTH|PASS|PRIVATE)/i.test(k)).sort(([a], [b]) => a.localeCompare(b));
|
|
1287
|
+
return {
|
|
1288
|
+
appInfo: [
|
|
1289
|
+
{ key: "App Version", value: profile.appVersion },
|
|
1290
|
+
{ key: "Node.js", value: profile.nodeVersion },
|
|
1291
|
+
{ key: "Environment", badge: { text: profile.environment, variant: profile.environment === "production" ? "warn" : "info" } },
|
|
1292
|
+
{ key: "Platform", value: `${process.platform} (${process.arch})` }
|
|
1293
|
+
],
|
|
1294
|
+
envRows: safeEnv.map(([key, v]) => ({ key, value: v ?? "", title: v ?? "", truncate: true })),
|
|
1295
|
+
envCount: safeEnv.length
|
|
1296
|
+
};
|
|
1297
|
+
}
|
|
1298
|
+
function buildExceptionPanel(profile) {
|
|
1299
|
+
const errLogs = profile.logs.filter((l) => l.level === "error" || l.level === "critical");
|
|
1300
|
+
const isError = profile.statusCode >= 500;
|
|
1301
|
+
const isNotFound = profile.statusCode === 404;
|
|
1302
|
+
return {
|
|
1303
|
+
noException: errLogs.length === 0 && !isError,
|
|
1304
|
+
showBanner: isError || isNotFound,
|
|
1305
|
+
statusCode: profile.statusCode,
|
|
1306
|
+
statusMessage: profile.statusMessage,
|
|
1307
|
+
method: profile.method,
|
|
1308
|
+
url: profile.url,
|
|
1309
|
+
errLogs: errLogs.map((l) => ({
|
|
1310
|
+
levelUpper: l.level.toUpperCase(),
|
|
1311
|
+
message: l.message,
|
|
1312
|
+
contextJson: l.context ? highlightJson(l.context) : null
|
|
1313
|
+
}))
|
|
1314
|
+
};
|
|
1315
|
+
}
|
|
1316
|
+
function pathToSegments(path) {
|
|
1317
|
+
const segments = [];
|
|
1318
|
+
const re = /:([a-zA-Z_][a-zA-Z0-9_]*)/g;
|
|
1319
|
+
let lastIndex = 0;
|
|
1320
|
+
let m;
|
|
1321
|
+
while ((m = re.exec(path)) !== null) {
|
|
1322
|
+
if (m.index > lastIndex) segments.push({ type: "text", value: path.slice(lastIndex, m.index) });
|
|
1323
|
+
segments.push({ type: "param", value: m[1] });
|
|
1324
|
+
lastIndex = re.lastIndex;
|
|
1325
|
+
}
|
|
1326
|
+
if (lastIndex < path.length) segments.push({ type: "text", value: path.slice(lastIndex) });
|
|
1327
|
+
return segments;
|
|
1328
|
+
}
|
|
1329
|
+
function methodClassOf(method) {
|
|
1330
|
+
return ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"].includes(method) ? `rm-${method}` : "rm-ALL";
|
|
1331
|
+
}
|
|
1332
|
+
function toRouteRows(routes) {
|
|
1333
|
+
return routes.map((r) => ({
|
|
1334
|
+
methodClass: methodClassOf(r.method),
|
|
1335
|
+
method: r.method,
|
|
1336
|
+
pathSegments: pathToSegments(r.path),
|
|
1337
|
+
middlewareCount: r.middlewareCount
|
|
1338
|
+
}));
|
|
1339
|
+
}
|
|
1340
|
+
function buildRoutesPanel(routes) {
|
|
1341
|
+
if (routes.length === 0) {
|
|
1342
|
+
return {
|
|
1343
|
+
hasRoutes: false,
|
|
1344
|
+
totalRoutes: 0,
|
|
1345
|
+
appRoutesCount: 0,
|
|
1346
|
+
methodCounts: [],
|
|
1347
|
+
hasAppRoutes: false,
|
|
1348
|
+
appRoutesRows: [],
|
|
1349
|
+
hasDebugRoutes: false,
|
|
1350
|
+
debugRoutesRows: []
|
|
1351
|
+
};
|
|
1352
|
+
}
|
|
1353
|
+
const methods = [...new Set(routes.map((r) => r.method))].sort();
|
|
1354
|
+
const appRoutes = routes.filter((r) => !r.path.startsWith("/_profiler") && r.path !== "/");
|
|
1355
|
+
const debugRoutes = routes.filter((r) => r.path.startsWith("/_profiler") || r.path === "/");
|
|
1356
|
+
return {
|
|
1357
|
+
hasRoutes: true,
|
|
1358
|
+
totalRoutes: routes.length,
|
|
1359
|
+
appRoutesCount: appRoutes.length,
|
|
1360
|
+
methodCounts: methods.map((m) => ({ method: m, count: routes.filter((r) => r.method === m).length })),
|
|
1361
|
+
hasAppRoutes: appRoutes.length > 0,
|
|
1362
|
+
appRoutesRows: toRouteRows(appRoutes),
|
|
1363
|
+
hasDebugRoutes: debugRoutes.length > 0,
|
|
1364
|
+
debugRoutesRows: toRouteRows(debugRoutes)
|
|
1365
|
+
};
|
|
1366
|
+
}
|
|
1367
|
+
function buildProfilerDetailViewModel(profile, panelName = "request", appRoutes = []) {
|
|
1368
|
+
const logErrors = profile.logs.filter((l) => l.level === "error" || l.level === "critical").length;
|
|
1369
|
+
const hasException = profile.statusCode >= 400 || logErrors > 0;
|
|
1370
|
+
let panelData;
|
|
1371
|
+
switch (panelName) {
|
|
1372
|
+
case "performance":
|
|
1373
|
+
panelData = buildPerformancePanel(profile);
|
|
1374
|
+
break;
|
|
1375
|
+
case "logs":
|
|
1376
|
+
panelData = buildLogsPanel(profile);
|
|
1377
|
+
break;
|
|
1378
|
+
case "routing":
|
|
1379
|
+
panelData = buildRoutingPanel(profile);
|
|
1380
|
+
break;
|
|
1381
|
+
case "configuration":
|
|
1382
|
+
panelData = buildConfigurationPanel(profile);
|
|
1383
|
+
break;
|
|
1384
|
+
case "database":
|
|
1385
|
+
panelData = buildDatabasePanel(profile);
|
|
1386
|
+
break;
|
|
1387
|
+
case "exception":
|
|
1388
|
+
panelData = buildExceptionPanel(profile);
|
|
1389
|
+
break;
|
|
1390
|
+
case "routes":
|
|
1391
|
+
panelData = buildRoutesPanel(appRoutes);
|
|
1392
|
+
break;
|
|
1393
|
+
default:
|
|
1394
|
+
panelData = buildRequestPanel(profile);
|
|
1395
|
+
}
|
|
1396
|
+
let banner;
|
|
1397
|
+
if (profile.statusCode >= 500) {
|
|
1398
|
+
banner = { bg: "#fcebec", border: "#e1142d", code: "#e1142d", msg: "#d98a8f", isError: true };
|
|
1399
|
+
} else if (profile.statusCode >= 400) {
|
|
1400
|
+
banner = { bg: "#fcebec", border: "#e1142d", code: "#e1142d", msg: "#d98a8f", isError: true };
|
|
1401
|
+
} else if (profile.statusCode >= 300) {
|
|
1402
|
+
banner = { bg: "#e3edf8", border: "#1565c0", code: "#1565c0", msg: "#6a9fd8", isError: false };
|
|
1403
|
+
} else {
|
|
1404
|
+
banner = { bg: "#e8f4ec", border: "#2e7d32", code: "#2e7d32", msg: "#6aaa6a", isError: false };
|
|
1405
|
+
}
|
|
1406
|
+
const panelLabel = {
|
|
1407
|
+
request: "panel.request",
|
|
1408
|
+
performance: "panel.performance",
|
|
1409
|
+
logs: "panel.logs",
|
|
1410
|
+
routing: "panel.routing",
|
|
1411
|
+
configuration: "panel.configuration",
|
|
1412
|
+
database: "panel.database",
|
|
1413
|
+
exception: "panel.exception",
|
|
1414
|
+
routes: "panel.routes"
|
|
1415
|
+
};
|
|
1416
|
+
const appRoutesForBadge = appRoutes.filter((r) => !r.path.startsWith("/_profiler") && r.path !== "/").length;
|
|
1417
|
+
const sidebarItems = [
|
|
1418
|
+
{ icon: "route", label: "panel.request", panel: "request", active: panelName === "request", badge: 0, badgeIsError: false },
|
|
1419
|
+
{ icon: "perf", label: "panel.performance", panel: "performance", active: panelName === "performance", badge: 0, badgeIsError: false },
|
|
1420
|
+
{
|
|
1421
|
+
icon: "exception",
|
|
1422
|
+
label: "panel.exception",
|
|
1423
|
+
panel: "exception",
|
|
1424
|
+
active: panelName === "exception",
|
|
1425
|
+
badge: hasException ? profile.statusCode >= 400 ? 1 : logErrors : 0,
|
|
1426
|
+
badgeIsError: true
|
|
1427
|
+
},
|
|
1428
|
+
{
|
|
1429
|
+
icon: "log",
|
|
1430
|
+
label: "panel.logs",
|
|
1431
|
+
panel: "logs",
|
|
1432
|
+
active: panelName === "logs",
|
|
1433
|
+
badge: profile.logs.length,
|
|
1434
|
+
badgeIsError: logErrors > 0
|
|
1435
|
+
},
|
|
1436
|
+
{
|
|
1437
|
+
icon: "map",
|
|
1438
|
+
label: "panel.routes",
|
|
1439
|
+
panel: "routes",
|
|
1440
|
+
active: panelName === "routes",
|
|
1441
|
+
badge: appRoutesForBadge,
|
|
1442
|
+
badgeIsError: false
|
|
1443
|
+
},
|
|
1444
|
+
{ icon: "route", label: "panel.routing", panel: "routing", active: panelName === "routing", badge: 0, badgeIsError: false },
|
|
1445
|
+
{
|
|
1446
|
+
icon: "db",
|
|
1447
|
+
label: "panel.database",
|
|
1448
|
+
panel: "database",
|
|
1449
|
+
active: panelName === "database",
|
|
1450
|
+
badge: profile.queries.length,
|
|
1451
|
+
badgeIsError: false
|
|
1452
|
+
},
|
|
1453
|
+
{ icon: "config", label: "panel.configuration", panel: "configuration", active: panelName === "configuration", badge: 0, badgeIsError: false }
|
|
1454
|
+
];
|
|
1455
|
+
return {
|
|
1456
|
+
profile: {
|
|
1457
|
+
method: profile.method,
|
|
1458
|
+
url: profile.url,
|
|
1459
|
+
statusCode: profile.statusCode,
|
|
1460
|
+
statusMessage: profile.statusMessage,
|
|
1461
|
+
ip: profile.request.ip,
|
|
1462
|
+
date: new Date(profile.timestamp).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric" }),
|
|
1463
|
+
time: new Date(profile.timestamp).toLocaleTimeString("en-US", { hour: "2-digit", minute: "2-digit", second: "2-digit" }),
|
|
1464
|
+
token6: profile.token.slice(0, 6)
|
|
1465
|
+
},
|
|
1466
|
+
banner,
|
|
1467
|
+
sidebarItems,
|
|
1468
|
+
panelName,
|
|
1469
|
+
panelLabel: panelLabel[panelName],
|
|
1470
|
+
panelData
|
|
1471
|
+
};
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
// src/controllers/profiler.controller.ts
|
|
1475
|
+
var SSE_HEARTBEAT_MS = 15e3;
|
|
1476
|
+
var PANELS = [
|
|
1477
|
+
"request",
|
|
1478
|
+
"performance",
|
|
1479
|
+
"logs",
|
|
1480
|
+
"routing",
|
|
1481
|
+
"configuration",
|
|
1482
|
+
"database",
|
|
1483
|
+
"exception",
|
|
1484
|
+
"routes"
|
|
1485
|
+
];
|
|
1486
|
+
function isValidPanel(p) {
|
|
1487
|
+
return typeof p === "string" && PANELS.includes(p);
|
|
1488
|
+
}
|
|
1489
|
+
function getNpmVersion() {
|
|
1490
|
+
try {
|
|
1491
|
+
return (0, import_child_process.execSync)("npm --version", { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
1492
|
+
} catch {
|
|
1493
|
+
return "\u2014";
|
|
1494
|
+
}
|
|
1495
|
+
}
|
|
1496
|
+
function createProfilerController(profiler) {
|
|
1497
|
+
const { storage, routePrefix, security, maxRequests, enabled } = profiler.options;
|
|
1498
|
+
async function recentLegacyProfiles(limit) {
|
|
1499
|
+
const profiles = await storage.find(limit);
|
|
1500
|
+
return profiles.map(toLegacyProfile);
|
|
1501
|
+
}
|
|
1502
|
+
return {
|
|
1503
|
+
async home(req, res) {
|
|
1504
|
+
const appVersion = process.env.npm_package_version ?? "1.0.0";
|
|
1505
|
+
const nodeVersion = process.version;
|
|
1506
|
+
const npmVersion = getNpmVersion();
|
|
1507
|
+
const environment = process.env.NODE_ENV ?? "development";
|
|
1508
|
+
await renderView(req, res, "home.njk", {
|
|
1509
|
+
appVersion,
|
|
1510
|
+
nodeVersion,
|
|
1511
|
+
environment,
|
|
1512
|
+
port: process.env.APP_PORT ?? "4200",
|
|
1513
|
+
host: process.env.APP_HOST ?? "localhost",
|
|
1514
|
+
showToolbar: enabled,
|
|
1515
|
+
isProd: environment === "production"
|
|
1516
|
+
});
|
|
1517
|
+
},
|
|
1518
|
+
async wdt(req, res) {
|
|
1519
|
+
const raw = await storage.read(req.params.token);
|
|
1520
|
+
if (!raw) {
|
|
1521
|
+
res.status(404).send("");
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
const profile = toLegacyProfile(raw);
|
|
1525
|
+
const recent = await recentLegacyProfiles(maxRequests);
|
|
1526
|
+
const vm = buildToolbarBarViewModel(profile, recent, security, routePrefix, maxRequests);
|
|
1527
|
+
await renderView(req, res, "toolbar-wdt.njk", { ...vm, profilerBase: routePrefix });
|
|
1528
|
+
},
|
|
1529
|
+
async list(req, res) {
|
|
1530
|
+
const qs = req.query;
|
|
1531
|
+
const str = (k) => typeof qs[k] === "string" ? qs[k] : "";
|
|
1532
|
+
const search = str("search");
|
|
1533
|
+
const method = str("method");
|
|
1534
|
+
const status = str("status");
|
|
1535
|
+
const ip = str("ip");
|
|
1536
|
+
const token = str("token");
|
|
1537
|
+
const from = str("from");
|
|
1538
|
+
const until = str("until");
|
|
1539
|
+
const limitRaw = typeof qs.limit === "string" ? parseInt(qs.limit, 10) : 10;
|
|
1540
|
+
const limit = [10, 25, 50, 100].includes(limitRaw) ? limitRaw : 10;
|
|
1541
|
+
const tab = qs.tab === "commands" ? "commands" : "requests";
|
|
1542
|
+
const all = await recentLegacyProfiles(1e3);
|
|
1543
|
+
await renderView(req, res, "profiler-list.njk", buildProfilerListViewModel(
|
|
1544
|
+
all,
|
|
1545
|
+
search,
|
|
1546
|
+
method,
|
|
1547
|
+
status,
|
|
1548
|
+
limit,
|
|
1549
|
+
ip,
|
|
1550
|
+
token,
|
|
1551
|
+
from,
|
|
1552
|
+
until,
|
|
1553
|
+
tab
|
|
1554
|
+
));
|
|
1555
|
+
},
|
|
1556
|
+
/**
|
|
1557
|
+
* Server-Sent Events stream: pushes one "profile" event per request
|
|
1558
|
+
* the server finishes profiling, formatted exactly like a
|
|
1559
|
+
* profiler-list.njk row. Replaces the old `setTimeout(location.reload)`
|
|
1560
|
+
* polling on the list page — the client only re-renders when the
|
|
1561
|
+
* server actually has something new, nothing is polled.
|
|
1562
|
+
*/
|
|
1563
|
+
stream(req, res) {
|
|
1564
|
+
res.setHeader("Content-Type", "text/event-stream");
|
|
1565
|
+
res.setHeader("Cache-Control", "no-cache, no-transform");
|
|
1566
|
+
res.setHeader("Connection", "keep-alive");
|
|
1567
|
+
res.setHeader("X-Accel-Buffering", "no");
|
|
1568
|
+
res.flushHeaders?.();
|
|
1569
|
+
const send = (event, data) => {
|
|
1570
|
+
res.write(`event: ${event}
|
|
1571
|
+
data: ${JSON.stringify(data)}
|
|
1572
|
+
|
|
1573
|
+
`);
|
|
1574
|
+
};
|
|
1575
|
+
const onProfile = (profile) => {
|
|
1576
|
+
send("profile", toProfilerListRow(toLegacyProfile(profile)));
|
|
1577
|
+
};
|
|
1578
|
+
profiler.events.on("profile", onProfile);
|
|
1579
|
+
const heartbeat = setInterval(() => res.write(": heartbeat\n\n"), SSE_HEARTBEAT_MS);
|
|
1580
|
+
req.on("close", () => {
|
|
1581
|
+
clearInterval(heartbeat);
|
|
1582
|
+
profiler.events.off("profile", onProfile);
|
|
1583
|
+
});
|
|
1584
|
+
},
|
|
1585
|
+
async latestRedirect(_req, res) {
|
|
1586
|
+
const [latest] = await storage.find(1);
|
|
1587
|
+
if (!latest) {
|
|
1588
|
+
res.redirect(routePrefix);
|
|
1589
|
+
return;
|
|
1590
|
+
}
|
|
1591
|
+
res.redirect(`${routePrefix}/${latest.token}`);
|
|
1592
|
+
},
|
|
1593
|
+
async detail(req, res) {
|
|
1594
|
+
const raw = await storage.read(req.params.token);
|
|
1595
|
+
if (!raw) {
|
|
1596
|
+
await renderView(req, res, "message.njk", {
|
|
1597
|
+
titleKey: "message.not_found_title",
|
|
1598
|
+
headingKey: "message.not_found_heading",
|
|
1599
|
+
messageKey: "message.not_found_body",
|
|
1600
|
+
messageParams: { token: req.params.token },
|
|
1601
|
+
link: { href: routePrefix, labelKey: "message.back_to_list" }
|
|
1602
|
+
});
|
|
1603
|
+
return;
|
|
1604
|
+
}
|
|
1605
|
+
const profile = toLegacyProfile(raw);
|
|
1606
|
+
const rawPanel = req.query.panel;
|
|
1607
|
+
const panel = isValidPanel(rawPanel) ? rawPanel : "request";
|
|
1608
|
+
await renderView(
|
|
1609
|
+
req,
|
|
1610
|
+
res,
|
|
1611
|
+
"profiler-detail.njk",
|
|
1612
|
+
buildProfilerDetailViewModel(profile, panel, getExpressRoutes())
|
|
1613
|
+
);
|
|
1614
|
+
}
|
|
1615
|
+
};
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
// src/module.ts
|
|
1619
|
+
function registerProfilerViews(app, profiler) {
|
|
1620
|
+
setIntrospectedApp(app);
|
|
1621
|
+
configureProfilerViewEngine(app);
|
|
1622
|
+
app.use(`${profiler.options.routePrefix}/assets`, import_opticore_express.express.static((0, import_path3.join)(__dirname, "assets"), { maxAge: 0, etag: true }));
|
|
1623
|
+
const controller = createProfilerController(profiler);
|
|
1624
|
+
app.use(import_opticore_router.OpticoreRoutingFactory.route({
|
|
1625
|
+
method: "get",
|
|
1626
|
+
path: "/",
|
|
1627
|
+
handler: (async (ctx) => {
|
|
1628
|
+
await controller.home(ctx.req, ctx.res);
|
|
1629
|
+
})
|
|
1630
|
+
}));
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
// src/routes/profiler.router.handler.ts
|
|
1634
|
+
var import_opticore_router2 = require("opticore-router");
|
|
1635
|
+
function createProfilerRouterHandler(profiler) {
|
|
1636
|
+
const controller = createProfilerController(profiler);
|
|
1637
|
+
const prefix = profiler.options.routePrefix;
|
|
1638
|
+
return import_opticore_router2.OpticoreRoutingFactory.routes(
|
|
1639
|
+
controller,
|
|
1640
|
+
[
|
|
1641
|
+
{
|
|
1642
|
+
path: `${prefix}/wdt/:token`,
|
|
1643
|
+
method: "get",
|
|
1644
|
+
middlewares: [],
|
|
1645
|
+
handler: async (ctx) => controller.wdt(ctx.req, ctx.res)
|
|
1646
|
+
},
|
|
1647
|
+
{
|
|
1648
|
+
path: `${prefix}/stream`,
|
|
1649
|
+
method: "get",
|
|
1650
|
+
middlewares: [],
|
|
1651
|
+
handler: async (ctx) => controller.stream(ctx.req, ctx.res)
|
|
1652
|
+
},
|
|
1653
|
+
{
|
|
1654
|
+
path: `${prefix}/latest`,
|
|
1655
|
+
method: "get",
|
|
1656
|
+
middlewares: [],
|
|
1657
|
+
handler: async (ctx) => controller.latestRedirect(ctx.req, ctx.res)
|
|
1658
|
+
},
|
|
1659
|
+
{
|
|
1660
|
+
path: `${prefix}/:token`,
|
|
1661
|
+
method: "get",
|
|
1662
|
+
middlewares: [],
|
|
1663
|
+
handler: async (ctx) => controller.detail(ctx.req, ctx.res)
|
|
1664
|
+
},
|
|
1665
|
+
{
|
|
1666
|
+
path: prefix,
|
|
1667
|
+
method: "get",
|
|
1668
|
+
middlewares: [],
|
|
1669
|
+
handler: async (ctx) => controller.list(ctx.req, ctx.res)
|
|
1670
|
+
}
|
|
1671
|
+
]
|
|
1672
|
+
);
|
|
1673
|
+
}
|
|
1674
|
+
|
|
1675
|
+
// src/routes/profiler.router.ts
|
|
1676
|
+
function createProfilerRouter(profiler) {
|
|
1677
|
+
const def = createProfilerRouterHandler(profiler);
|
|
1678
|
+
return {
|
|
1679
|
+
routes: [
|
|
1680
|
+
{ path: def.path, handler: def.handler }
|
|
1681
|
+
]
|
|
1682
|
+
};
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// src/instrumentation/mysql.instrumentation.ts
|
|
1686
|
+
var import_path4 = require("path");
|
|
1687
|
+
var INSTRUMENTED = /* @__PURE__ */ new WeakSet();
|
|
1688
|
+
function extractSql(sql) {
|
|
1689
|
+
return typeof sql === "string" ? sql : sql.sql;
|
|
1690
|
+
}
|
|
1691
|
+
function captureCallSite() {
|
|
1692
|
+
const probe = { stack: "" };
|
|
1693
|
+
Error.captureStackTrace(probe, captureCallSite);
|
|
1694
|
+
const lines = probe.stack.split("\n").slice(1);
|
|
1695
|
+
const appFrame = lines.find(
|
|
1696
|
+
(l) => !l.includes("mysql.instrumentation") && !l.includes(`${import_path4.sep}opticore-mysqldb${import_path4.sep}`)
|
|
1697
|
+
);
|
|
1698
|
+
return (appFrame ?? lines[0] ?? "").trim();
|
|
1699
|
+
}
|
|
1700
|
+
function instrumentMySQL(DriverClass) {
|
|
1701
|
+
const proto = DriverClass.prototype;
|
|
1702
|
+
if (INSTRUMENTED.has(proto)) return;
|
|
1703
|
+
INSTRUMENTED.add(proto);
|
|
1704
|
+
const original = proto.makeQuery;
|
|
1705
|
+
proto.makeQuery = async function(sql, values, callback) {
|
|
1706
|
+
const collector = getActiveCollector("database");
|
|
1707
|
+
if (!collector) {
|
|
1708
|
+
return original.call(this, sql, values, callback);
|
|
1709
|
+
}
|
|
1710
|
+
const sqlText = extractSql(sql);
|
|
1711
|
+
const stack = captureCallSite();
|
|
1712
|
+
const start = process.hrtime.bigint();
|
|
1713
|
+
try {
|
|
1714
|
+
const result = await original.call(this, sql, values, callback);
|
|
1715
|
+
collector.record({
|
|
1716
|
+
sql: sqlText,
|
|
1717
|
+
bindings: values,
|
|
1718
|
+
duration: Number((process.hrtime.bigint() - start) / 1000000n),
|
|
1719
|
+
stack
|
|
1720
|
+
});
|
|
1721
|
+
return result;
|
|
1722
|
+
} catch (err) {
|
|
1723
|
+
collector.record({
|
|
1724
|
+
sql: sqlText,
|
|
1725
|
+
bindings: values,
|
|
1726
|
+
duration: Number((process.hrtime.bigint() - start) / 1000000n),
|
|
1727
|
+
stack,
|
|
1728
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1729
|
+
});
|
|
1730
|
+
throw err;
|
|
1731
|
+
}
|
|
1732
|
+
};
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
// src/instrumentation/logger.instrumentation.ts
|
|
1736
|
+
var INSTRUMENTED2 = /* @__PURE__ */ new WeakSet();
|
|
1737
|
+
function mirror(level, message, context) {
|
|
1738
|
+
const collector = getActiveCollector("logger");
|
|
1739
|
+
if (!collector) return;
|
|
1740
|
+
collector.record({ level, message, context });
|
|
1741
|
+
}
|
|
1742
|
+
function instrumentLogger(LoggerClass) {
|
|
1743
|
+
const proto = LoggerClass.prototype;
|
|
1744
|
+
if (INSTRUMENTED2.has(proto)) {
|
|
1745
|
+
return;
|
|
1746
|
+
}
|
|
1747
|
+
INSTRUMENTED2.add(proto);
|
|
1748
|
+
const originalSuccess = proto.success;
|
|
1749
|
+
proto.success = function(arg) {
|
|
1750
|
+
mirror("info", arg.message ?? arg.title ?? "", arg.title ? { title: arg.title } : void 0);
|
|
1751
|
+
return originalSuccess.call(this, arg);
|
|
1752
|
+
};
|
|
1753
|
+
const originalInfo = proto.info;
|
|
1754
|
+
proto.info = function(arg) {
|
|
1755
|
+
mirror("info", arg.message ?? arg.title ?? "", arg.title ? { title: arg.title } : void 0);
|
|
1756
|
+
return originalInfo.call(this, arg);
|
|
1757
|
+
};
|
|
1758
|
+
const originalWarn = proto.warn;
|
|
1759
|
+
proto.warn = function(arg) {
|
|
1760
|
+
mirror("warning", arg.message, arg.title ? { title: arg.title } : void 0);
|
|
1761
|
+
return originalWarn.call(this, arg);
|
|
1762
|
+
};
|
|
1763
|
+
const originalError = proto.error;
|
|
1764
|
+
proto.error = function(arg) {
|
|
1765
|
+
mirror("error", arg.message, {
|
|
1766
|
+
...arg.title ? { title: arg.title } : {},
|
|
1767
|
+
...arg.errorType ? { errorType: arg.errorType } : {},
|
|
1768
|
+
...arg.stackTrace ? { stack: arg.stackTrace } : {}
|
|
1769
|
+
});
|
|
1770
|
+
return originalError.call(this, arg);
|
|
1771
|
+
};
|
|
1772
|
+
const originalDebug = proto.debug;
|
|
1773
|
+
proto.debug = function(message) {
|
|
1774
|
+
mirror("debug", message);
|
|
1775
|
+
return originalDebug.call(this, message);
|
|
1776
|
+
};
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
// src/storage/memory.storage.ts
|
|
1780
|
+
var MemoryStorage = class {
|
|
1781
|
+
constructor(limit = 100) {
|
|
1782
|
+
this.limit = limit;
|
|
1783
|
+
if (limit < 1) throw new Error("MemoryStorage limit must be >= 1");
|
|
1784
|
+
}
|
|
1785
|
+
limit;
|
|
1786
|
+
profiles = /* @__PURE__ */ new Map();
|
|
1787
|
+
order = [];
|
|
1788
|
+
write(profile) {
|
|
1789
|
+
if (this.profiles.has(profile.token)) {
|
|
1790
|
+
const idx = this.order.indexOf(profile.token);
|
|
1791
|
+
if (idx !== -1) this.order.splice(idx, 1);
|
|
1792
|
+
} else if (this.order.length >= this.limit) {
|
|
1793
|
+
const oldest = this.order.shift();
|
|
1794
|
+
this.profiles.delete(oldest);
|
|
1795
|
+
}
|
|
1796
|
+
this.order.push(profile.token);
|
|
1797
|
+
this.profiles.set(profile.token, profile);
|
|
1798
|
+
}
|
|
1799
|
+
read(token) {
|
|
1800
|
+
return this.profiles.get(token);
|
|
1801
|
+
}
|
|
1802
|
+
find(limit) {
|
|
1803
|
+
const tokens = [...this.order].reverse();
|
|
1804
|
+
const sliced = limit ? tokens.slice(0, limit) : tokens;
|
|
1805
|
+
return sliced.map((t) => this.profiles.get(t)).filter(Boolean);
|
|
1806
|
+
}
|
|
1807
|
+
purge(olderThanMs) {
|
|
1808
|
+
if (!olderThanMs) return;
|
|
1809
|
+
const cutoff = Date.now() - olderThanMs;
|
|
1810
|
+
for (const token of [...this.order]) {
|
|
1811
|
+
const profile = this.profiles.get(token);
|
|
1812
|
+
if (profile && profile.time < cutoff) {
|
|
1813
|
+
this.profiles.delete(token);
|
|
1814
|
+
const idx = this.order.indexOf(token);
|
|
1815
|
+
if (idx !== -1) this.order.splice(idx, 1);
|
|
1816
|
+
}
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
clear() {
|
|
1820
|
+
this.profiles.clear();
|
|
1821
|
+
this.order.length = 0;
|
|
1822
|
+
}
|
|
1823
|
+
count() {
|
|
1824
|
+
return this.order.length;
|
|
1825
|
+
}
|
|
1826
|
+
};
|
|
1827
|
+
|
|
1828
|
+
// src/storage/file.storage.ts
|
|
1829
|
+
var import_fs2 = require("fs");
|
|
1830
|
+
var import_path5 = require("path");
|
|
1831
|
+
var FileStorage = class {
|
|
1832
|
+
constructor(dir = ".opticore-profiler-cache", maxIndexSize = 100) {
|
|
1833
|
+
this.dir = dir;
|
|
1834
|
+
this.maxIndexSize = maxIndexSize;
|
|
1835
|
+
if (maxIndexSize < 1) throw new Error("FileStorage maxIndexSize must be >= 1");
|
|
1836
|
+
this.indexPath = (0, import_path5.join)(this.dir, "index.json");
|
|
1837
|
+
this.ensureDir();
|
|
1838
|
+
}
|
|
1839
|
+
dir;
|
|
1840
|
+
maxIndexSize;
|
|
1841
|
+
indexPath;
|
|
1842
|
+
ensureDir() {
|
|
1843
|
+
if (!(0, import_fs2.existsSync)(this.dir)) (0, import_fs2.mkdirSync)(this.dir, { recursive: true });
|
|
1844
|
+
}
|
|
1845
|
+
profilePath(token) {
|
|
1846
|
+
return (0, import_path5.join)(this.dir, `${token}.json`);
|
|
1847
|
+
}
|
|
1848
|
+
readIndex() {
|
|
1849
|
+
if (!(0, import_fs2.existsSync)(this.indexPath)) return [];
|
|
1850
|
+
try {
|
|
1851
|
+
return JSON.parse((0, import_fs2.readFileSync)(this.indexPath, "utf8"));
|
|
1852
|
+
} catch {
|
|
1853
|
+
return [];
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
writeIndex(entries) {
|
|
1857
|
+
(0, import_fs2.writeFileSync)(this.indexPath, JSON.stringify(entries), "utf8");
|
|
1858
|
+
}
|
|
1859
|
+
write(profile) {
|
|
1860
|
+
this.ensureDir();
|
|
1861
|
+
(0, import_fs2.writeFileSync)(this.profilePath(profile.token), JSON.stringify(profile), "utf8");
|
|
1862
|
+
const index = this.readIndex().filter((e) => e.token !== profile.token);
|
|
1863
|
+
index.unshift({
|
|
1864
|
+
token: profile.token,
|
|
1865
|
+
ip: profile.ip,
|
|
1866
|
+
method: profile.method,
|
|
1867
|
+
url: profile.url,
|
|
1868
|
+
statusCode: profile.statusCode,
|
|
1869
|
+
time: profile.time,
|
|
1870
|
+
duration: profile.duration,
|
|
1871
|
+
contentType: profile.contentType
|
|
1872
|
+
});
|
|
1873
|
+
while (index.length > this.maxIndexSize) {
|
|
1874
|
+
const dropped = index.pop();
|
|
1875
|
+
this.deleteFile(dropped.token);
|
|
1876
|
+
}
|
|
1877
|
+
this.writeIndex(index);
|
|
1878
|
+
}
|
|
1879
|
+
read(token) {
|
|
1880
|
+
const path = this.profilePath(token);
|
|
1881
|
+
if (!(0, import_fs2.existsSync)(path)) return void 0;
|
|
1882
|
+
try {
|
|
1883
|
+
return JSON.parse((0, import_fs2.readFileSync)(path, "utf8"));
|
|
1884
|
+
} catch {
|
|
1885
|
+
return void 0;
|
|
1886
|
+
}
|
|
1887
|
+
}
|
|
1888
|
+
find(limit) {
|
|
1889
|
+
const entries = this.readIndex();
|
|
1890
|
+
const sliced = limit ? entries.slice(0, limit) : entries;
|
|
1891
|
+
return sliced.map((e) => ({ ...e, collectors: {} }));
|
|
1892
|
+
}
|
|
1893
|
+
purge(olderThanMs) {
|
|
1894
|
+
if (!olderThanMs) return;
|
|
1895
|
+
const cutoff = Date.now() - olderThanMs;
|
|
1896
|
+
const index = this.readIndex();
|
|
1897
|
+
const kept = index.filter((e) => {
|
|
1898
|
+
if (e.time >= cutoff) return true;
|
|
1899
|
+
this.deleteFile(e.token);
|
|
1900
|
+
return false;
|
|
1901
|
+
});
|
|
1902
|
+
if (kept.length !== index.length) this.writeIndex(kept);
|
|
1903
|
+
}
|
|
1904
|
+
clear() {
|
|
1905
|
+
for (const entry of this.readIndex()) {
|
|
1906
|
+
this.deleteFile(entry.token);
|
|
1907
|
+
}
|
|
1908
|
+
this.writeIndex([]);
|
|
1909
|
+
}
|
|
1910
|
+
deleteFile(token) {
|
|
1911
|
+
const path = this.profilePath(token);
|
|
1912
|
+
if ((0, import_fs2.existsSync)(path)) {
|
|
1913
|
+
try {
|
|
1914
|
+
(0, import_fs2.unlinkSync)(path);
|
|
1915
|
+
} catch {
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
}
|
|
1919
|
+
};
|
|
1920
|
+
// Annotate the CommonJS export names for ESM import in node:
|
|
1921
|
+
0 && (module.exports = {
|
|
1922
|
+
CollectorRegistry,
|
|
1923
|
+
DEFAULT_EXCLUDE_PATHS,
|
|
1924
|
+
DEFAULT_SENSITIVE_KEYS,
|
|
1925
|
+
DatabaseCollector,
|
|
1926
|
+
ExceptionCollector,
|
|
1927
|
+
FileStorage,
|
|
1928
|
+
LoggerCollector,
|
|
1929
|
+
MemoryCollector,
|
|
1930
|
+
MemoryStorage,
|
|
1931
|
+
RequestCollector,
|
|
1932
|
+
TimeCollector,
|
|
1933
|
+
createProfilerRouter,
|
|
1934
|
+
generateToken,
|
|
1935
|
+
getActiveCollector,
|
|
1936
|
+
instrumentLogger,
|
|
1937
|
+
instrumentMySQL,
|
|
1938
|
+
isValidToken,
|
|
1939
|
+
opticoreProfiler,
|
|
1940
|
+
profilerErrorHandler,
|
|
1941
|
+
registerProfilerViews
|
|
1942
|
+
});
|