@bhooai/nexus-core 2.0.1 → 2.0.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +27 -27
- package/src/app/adminModule.ts +365 -0
- package/src/app/createNexusApp.ts +31 -0
- package/src/app/index.ts +1 -0
package/package.json
CHANGED
|
@@ -1,28 +1,28 @@
|
|
|
1
1
|
{
|
|
2
|
-
"name":
|
|
3
|
-
"version":
|
|
4
|
-
"publishConfig":
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
"type":
|
|
8
|
-
"main":
|
|
9
|
-
"types":
|
|
10
|
-
"exports":
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
"scripts":
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
"dependencies":
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
"devDependencies":
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
2
|
+
"name": "@bhooai/nexus-core",
|
|
3
|
+
"version": "2.0.2",
|
|
4
|
+
"publishConfig": {
|
|
5
|
+
"access": "public"
|
|
6
|
+
},
|
|
7
|
+
"type": "module",
|
|
8
|
+
"main": "./src/index.ts",
|
|
9
|
+
"types": "./src/index.ts",
|
|
10
|
+
"exports": {
|
|
11
|
+
".": "./src/index.ts",
|
|
12
|
+
"./config": "./src/config/index.ts",
|
|
13
|
+
"./di": "./src/di/index.ts",
|
|
14
|
+
"./http": "./src/http/index.ts"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"test": "vitest run"
|
|
19
|
+
},
|
|
20
|
+
"dependencies": {
|
|
21
|
+
"zod": "^3.23.8"
|
|
22
|
+
},
|
|
23
|
+
"devDependencies": {
|
|
24
|
+
"@types/node": "^22.5.0",
|
|
25
|
+
"typescript": "^5.6.2",
|
|
26
|
+
"vitest": "^2.1.1"
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* adminModule — framework-provided admin backend routes.
|
|
3
|
+
*
|
|
4
|
+
* Auto-mounted by `createNexusApp()` when `config.admin.enabled`. Serves the
|
|
5
|
+
* admin SPA's tabs: registry, health/services, per-app cards, request logs,
|
|
6
|
+
* read-only config, users, payments, and the AI proxy.
|
|
7
|
+
*
|
|
8
|
+
* All DB-backed routes are lazy: Mongo is only connected when a route is hit,
|
|
9
|
+
* so boot is never blocked and single-node apps without Mongo still work
|
|
10
|
+
* (endpoints return { ok: false, reason: '...' }).
|
|
11
|
+
*/
|
|
12
|
+
import { existsSync } from 'node:fs';
|
|
13
|
+
import { readFile } from 'node:fs/promises';
|
|
14
|
+
import { createConnection } from 'node:net';
|
|
15
|
+
import { join, resolve } from 'node:path';
|
|
16
|
+
import type { NexusConfig } from '../config/types.js';
|
|
17
|
+
import type { Router } from '../http/Router.js';
|
|
18
|
+
import type { RequestContext } from '../http/context.js';
|
|
19
|
+
import { AiClient } from '@bhooai/nexus-ai-client';
|
|
20
|
+
|
|
21
|
+
export interface AdminModuleOptions {
|
|
22
|
+
name: string;
|
|
23
|
+
config: NexusConfig;
|
|
24
|
+
projectRoot: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface LazyDb {
|
|
28
|
+
/** Resolve a connected DB, or null if Mongo is unreachable. */
|
|
29
|
+
db(): Promise<import('mongodb').Db | null>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A bounded in-memory request log entry. */
|
|
33
|
+
export interface RequestLogEntry {
|
|
34
|
+
ts: number;
|
|
35
|
+
method: string;
|
|
36
|
+
path: string;
|
|
37
|
+
status: number;
|
|
38
|
+
latencyMs: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const MAX_LOG_ENTRIES = 1000;
|
|
42
|
+
|
|
43
|
+
/** In-memory ring buffer of recent requests (source for /admin/logs/tail). */
|
|
44
|
+
export class RequestLogBuffer {
|
|
45
|
+
private entries: RequestLogEntry[] = [];
|
|
46
|
+
|
|
47
|
+
push(entry: RequestLogEntry): void {
|
|
48
|
+
this.entries.push(entry);
|
|
49
|
+
if (this.entries.length > MAX_LOG_ENTRIES) {
|
|
50
|
+
this.entries.splice(0, this.entries.length - MAX_LOG_ENTRIES);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
tail(lines: number, level?: string): RequestLogEntry[] {
|
|
55
|
+
const n = Math.min(Math.max(lines, 1), MAX_LOG_ENTRIES);
|
|
56
|
+
const slice = this.entries.slice(-n);
|
|
57
|
+
if (!level) return slice;
|
|
58
|
+
return slice.filter((e) => levelOf(e.status) === level);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
clear(): void {
|
|
62
|
+
this.entries = [];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
get size(): number {
|
|
66
|
+
return this.entries.length;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function levelOf(status: number): string {
|
|
71
|
+
if (status >= 500) return 'error';
|
|
72
|
+
if (status >= 400) return 'warn';
|
|
73
|
+
return 'info';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Probe a TCP port with a short timeout. */
|
|
77
|
+
function tcpReachable(host: string, port: number, timeoutMs = 1200): Promise<boolean> {
|
|
78
|
+
return new Promise((res) => {
|
|
79
|
+
const socket = createConnection({ host, port, timeout: timeoutMs });
|
|
80
|
+
socket.once('connect', () => { socket.end(); res(true); });
|
|
81
|
+
socket.once('error', () => res(false));
|
|
82
|
+
socket.once('timeout', () => { socket.destroy(); res(false); });
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Register all admin + AI proxy routes onto the router. */
|
|
87
|
+
export function registerAdminRoutes(router: Router, opts: AdminModuleOptions, logBuffer: RequestLogBuffer, lazy: LazyDb): void {
|
|
88
|
+
const { config, projectRoot } = opts;
|
|
89
|
+
|
|
90
|
+
// ------------------------------------------------------------------
|
|
91
|
+
// /admin/health/services — TCP probes for infra services
|
|
92
|
+
// ------------------------------------------------------------------
|
|
93
|
+
router.get('/admin/health/services', async (ctx) => {
|
|
94
|
+
const mongoHost = hostOf(config.db.uri, 'localhost');
|
|
95
|
+
const mongoPort = portOf(config.db.uri, 27017);
|
|
96
|
+
const redisHost = hostOf(config.redis.url, 'localhost');
|
|
97
|
+
const redisPort = portOf(config.redis.url, 6379);
|
|
98
|
+
const aiPort = portOf(config.ai.serverUrl, 8000);
|
|
99
|
+
|
|
100
|
+
const [mongo, redis, ai] = await Promise.all([
|
|
101
|
+
tcpReachable(mongoHost, mongoPort),
|
|
102
|
+
tcpReachable(redisHost, redisPort),
|
|
103
|
+
tcpReachable('127.0.0.1', aiPort),
|
|
104
|
+
]);
|
|
105
|
+
|
|
106
|
+
ctx.json({
|
|
107
|
+
services: [
|
|
108
|
+
{ id: 'mongo', label: 'MongoDB', ok: mongo, detail: `${mongoHost}:${mongoPort}` },
|
|
109
|
+
{ id: 'redis', label: 'Redis', ok: redis, detail: `${redisHost}:${redisPort}` },
|
|
110
|
+
{ id: 'ai', label: 'AI server', ok: ai, detail: `127.0.0.1:${aiPort}` },
|
|
111
|
+
{ id: 'storage', label: 'Storage', ok: true, detail: 'local disks configured' },
|
|
112
|
+
],
|
|
113
|
+
});
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
// ------------------------------------------------------------------
|
|
117
|
+
// /admin/apps — registry + per-app /health probe
|
|
118
|
+
// ------------------------------------------------------------------
|
|
119
|
+
router.get('/admin/apps', async (ctx) => {
|
|
120
|
+
const registry = await readRegistry(projectRoot);
|
|
121
|
+
const apps = await Promise.all(
|
|
122
|
+
Object.entries(registry).map(async ([appName, port]) => {
|
|
123
|
+
const kind = kindOf(appName);
|
|
124
|
+
const probe = await probeHealth(port, kind);
|
|
125
|
+
return {
|
|
126
|
+
name: appName,
|
|
127
|
+
port,
|
|
128
|
+
healthy: probe.ok,
|
|
129
|
+
uptimeSec: probe.uptimeSec ?? null,
|
|
130
|
+
kind,
|
|
131
|
+
};
|
|
132
|
+
}),
|
|
133
|
+
);
|
|
134
|
+
ctx.json({ apps });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
// ------------------------------------------------------------------
|
|
138
|
+
// /admin/logs/tail — recent request logs
|
|
139
|
+
// ------------------------------------------------------------------
|
|
140
|
+
router.get('/admin/logs/tail', (ctx) => {
|
|
141
|
+
const lines = parseInt(String(ctx.query.lines ?? '100'), 10) || 100;
|
|
142
|
+
const level = (ctx.query.level as string) || undefined;
|
|
143
|
+
ctx.json({ entries: logBuffer.tail(lines, level) });
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
router.post('/admin/logs/clear', (ctx) => {
|
|
147
|
+
logBuffer.clear();
|
|
148
|
+
ctx.json({ ok: true });
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
// ------------------------------------------------------------------
|
|
152
|
+
// /admin/config — read-only, redacted
|
|
153
|
+
// ------------------------------------------------------------------
|
|
154
|
+
router.get('/admin/config', (ctx) => {
|
|
155
|
+
ctx.json({ config: redactConfig(config) });
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
// ------------------------------------------------------------------
|
|
159
|
+
// /admin/users + /admin/payments — lazy Mongo
|
|
160
|
+
// ------------------------------------------------------------------
|
|
161
|
+
router.get('/admin/users', async (ctx) => {
|
|
162
|
+
const db = await lazy.db();
|
|
163
|
+
if (!db) {
|
|
164
|
+
ctx.json({ ok: false, reason: 'MongoDB not reachable' });
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
try {
|
|
168
|
+
const users = await db.collection('users').find({}).sort({ createdAt: -1 }).limit(200).toArray();
|
|
169
|
+
ctx.json({
|
|
170
|
+
ok: true,
|
|
171
|
+
users: users.map((u) => ({
|
|
172
|
+
id: String(u._id),
|
|
173
|
+
email: u.email,
|
|
174
|
+
name: u.name,
|
|
175
|
+
roles: u.roles ?? ['user'],
|
|
176
|
+
createdAt: u.createdAt,
|
|
177
|
+
})),
|
|
178
|
+
});
|
|
179
|
+
} catch (err) {
|
|
180
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
181
|
+
}
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
router.get('/admin/payments', async (ctx) => {
|
|
185
|
+
const db = await lazy.db();
|
|
186
|
+
if (!db) {
|
|
187
|
+
ctx.json({ ok: false, reason: 'MongoDB not reachable' });
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
const orders = await db.collection('orders').find({}).sort({ createdAt: -1 }).limit(50).toArray();
|
|
192
|
+
ctx.json({
|
|
193
|
+
ok: true,
|
|
194
|
+
orders: orders.map((o) => ({
|
|
195
|
+
id: String(o._id),
|
|
196
|
+
userId: o.userId,
|
|
197
|
+
provider: o.provider,
|
|
198
|
+
amount: o.amount,
|
|
199
|
+
currency: o.currency,
|
|
200
|
+
status: o.status,
|
|
201
|
+
createdAt: o.createdAt,
|
|
202
|
+
})),
|
|
203
|
+
providers: providerStatus(config),
|
|
204
|
+
});
|
|
205
|
+
} catch (err) {
|
|
206
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
// ------------------------------------------------------------------
|
|
211
|
+
// /ai/* — proxy to the Python AI server via AiClient
|
|
212
|
+
// ------------------------------------------------------------------
|
|
213
|
+
const aiClient = new AiClient({
|
|
214
|
+
serverUrl: config.ai.serverUrl,
|
|
215
|
+
timeoutMs: config.ai.timeoutMs,
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
router.get('/ai/models', async (ctx) => {
|
|
219
|
+
try {
|
|
220
|
+
const models = await aiClient.listModels();
|
|
221
|
+
ctx.json({ ok: true, models });
|
|
222
|
+
} catch (err) {
|
|
223
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
224
|
+
}
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
router.post('/ai/chat', async (ctx) => {
|
|
228
|
+
const body = ctx.body as { model?: string; messages?: unknown[]; temperature?: number; max_tokens?: number; provider?: 'openai' | 'ollama' | 'auto' };
|
|
229
|
+
if (!body?.messages) {
|
|
230
|
+
ctx.json({ ok: false, reason: 'messages is required' }, 400);
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
try {
|
|
234
|
+
const res = await aiClient.chat({
|
|
235
|
+
model: body.model ?? config.ai.schemaModel,
|
|
236
|
+
messages: body.messages as never,
|
|
237
|
+
temperature: body.temperature,
|
|
238
|
+
max_tokens: body.max_tokens,
|
|
239
|
+
provider: body.provider,
|
|
240
|
+
});
|
|
241
|
+
ctx.json({ ok: true, ...res });
|
|
242
|
+
} catch (err) {
|
|
243
|
+
ctx.json({ ok: false, reason: String((err as Error).message) });
|
|
244
|
+
}
|
|
245
|
+
});
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Helpers
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
function kindOf(appName: string): string {
|
|
253
|
+
if (appName.startsWith('backend')) return 'backend';
|
|
254
|
+
if (appName.startsWith('frontend')) return 'frontend';
|
|
255
|
+
if (appName.startsWith('admin')) return 'admin';
|
|
256
|
+
return 'other';
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Probe a registered app's liveness.
|
|
261
|
+
*
|
|
262
|
+
* Backend / ai-server / other apps expose a JSON /health with `{ ok: true }`.
|
|
263
|
+
* Frontend and admin are Vite SPAs — they return HTML for any path (history
|
|
264
|
+
* fallback), so a bare 200 on `/` means the dev server is up.
|
|
265
|
+
*/
|
|
266
|
+
async function probeHealth(port: number, kind: string): Promise<{ ok: boolean; uptimeSec?: number }> {
|
|
267
|
+
try {
|
|
268
|
+
const controller = new AbortController();
|
|
269
|
+
const timer = setTimeout(() => controller.abort(), 1500);
|
|
270
|
+
const isSpa = kind === 'frontend' || kind === 'admin';
|
|
271
|
+
const path = isSpa ? '/' : '/health';
|
|
272
|
+
const res = await fetch(`http://127.0.0.1:${port}${path}`, { signal: controller.signal });
|
|
273
|
+
clearTimeout(timer);
|
|
274
|
+
if (isSpa) return { ok: res.ok };
|
|
275
|
+
const data = (await res.json()) as { ok?: boolean };
|
|
276
|
+
return { ok: !!data.ok };
|
|
277
|
+
} catch {
|
|
278
|
+
return { ok: false };
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
async function readRegistry(projectRoot: string): Promise<Record<string, number>> {
|
|
283
|
+
const p = resolve(projectRoot, '.nexus-ports.json');
|
|
284
|
+
if (!existsSync(p)) return {};
|
|
285
|
+
try {
|
|
286
|
+
return JSON.parse(await readFile(p, 'utf-8')) as Record<string, number>;
|
|
287
|
+
} catch {
|
|
288
|
+
return {};
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function hostOf(uri: string, fallback: string): string {
|
|
293
|
+
try {
|
|
294
|
+
const u = new URL(uri);
|
|
295
|
+
return u.hostname || fallback;
|
|
296
|
+
} catch {
|
|
297
|
+
return fallback;
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function portOf(uri: string, fallback: number): number {
|
|
302
|
+
try {
|
|
303
|
+
const u = new URL(uri);
|
|
304
|
+
return u.port ? parseInt(u.port, 10) : fallback;
|
|
305
|
+
} catch {
|
|
306
|
+
return fallback;
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** Deep-clone config, masking secret-ish fields. */
|
|
311
|
+
function redactConfig(config: NexusConfig): Record<string, unknown> {
|
|
312
|
+
const clone = JSON.parse(JSON.stringify(config)) as Record<string, unknown>;
|
|
313
|
+
redact(clone, /(secret|token|clientsecret|apikey|keyid|password|pass|refresh)/i);
|
|
314
|
+
return clone;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function redact(obj: unknown, pattern: RegExp): void {
|
|
318
|
+
if (!obj || typeof obj !== 'object') return;
|
|
319
|
+
if (Array.isArray(obj)) {
|
|
320
|
+
for (const item of obj) redact(item, pattern);
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
for (const [k, v] of Object.entries(obj)) {
|
|
324
|
+
if (typeof v === 'string' && pattern.test(k)) {
|
|
325
|
+
(obj as Record<string, unknown>)[k] = '••••••••';
|
|
326
|
+
} else if (v && typeof v === 'object') {
|
|
327
|
+
redact(v, pattern);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function providerStatus(config: NexusConfig): Array<{ id: string; enabled: boolean; sandbox: boolean }> {
|
|
333
|
+
const p = config.payments as unknown as Record<string, unknown>;
|
|
334
|
+
const out: Array<{ id: string; enabled: boolean; sandbox: boolean }> = [];
|
|
335
|
+
for (const [id, val] of Object.entries(p)) {
|
|
336
|
+
if (typeof val === 'object' && val !== null) {
|
|
337
|
+
const v = val as { enabled?: boolean; sandbox?: boolean };
|
|
338
|
+
if (typeof v.enabled === 'boolean') {
|
|
339
|
+
out.push({ id, enabled: v.enabled, sandbox: !!v.sandbox });
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return out;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/** Convenience for wiring: create a lazy Mongo db resolver from a config. */
|
|
347
|
+
export function createLazyDb(config: NexusConfig): LazyDb {
|
|
348
|
+
let cached: import('mongodb').Db | null | undefined;
|
|
349
|
+
return {
|
|
350
|
+
async db() {
|
|
351
|
+
if (cached !== undefined) return cached;
|
|
352
|
+
try {
|
|
353
|
+
const { connect } = await import('@bhooai/nexus-data');
|
|
354
|
+
const connection = connect(config.db.uri, { maxPoolSize: 2 });
|
|
355
|
+
cached = await connection.db;
|
|
356
|
+
return cached;
|
|
357
|
+
} catch (err) {
|
|
358
|
+
cached = null;
|
|
359
|
+
return null;
|
|
360
|
+
}
|
|
361
|
+
},
|
|
362
|
+
};
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
export type { RequestContext };
|
|
@@ -30,6 +30,7 @@ import { configureMailDriver, configureMailRenderer, logMailDriver } from './Mai
|
|
|
30
30
|
import { policyRegistry } from './policies.js';
|
|
31
31
|
import { configureStorageFromEnv } from './Storage.js';
|
|
32
32
|
import { maintenanceMiddleware } from './maintenance.js';
|
|
33
|
+
import { createLazyDb, registerAdminRoutes, RequestLogBuffer } from './adminModule.js';
|
|
33
34
|
|
|
34
35
|
export interface CreateNexusAppOptions {
|
|
35
36
|
/** Identifier for this backend, used in admin, telemetry, logs. */
|
|
@@ -228,6 +229,16 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
228
229
|
ctx.json({ apps });
|
|
229
230
|
});
|
|
230
231
|
|
|
232
|
+
// ------------------------------------------------------------------
|
|
233
|
+
// Admin module — request log buffer, lazy DB, admin + AI proxy routes
|
|
234
|
+
// ------------------------------------------------------------------
|
|
235
|
+
const logBuffer = new RequestLogBuffer();
|
|
236
|
+
const lazyDb = createLazyDb(config);
|
|
237
|
+
|
|
238
|
+
if (config.admin.enabled) {
|
|
239
|
+
registerAdminRoutes(router, { name, config, projectRoot }, logBuffer, lazyDb);
|
|
240
|
+
}
|
|
241
|
+
|
|
231
242
|
// ------------------------------------------------------------------
|
|
232
243
|
// HTTP server with error handler + maintenance middleware wired in
|
|
233
244
|
// ------------------------------------------------------------------
|
|
@@ -244,6 +255,26 @@ export async function createNexusApp(opts: CreateNexusAppOptions = {}): Promise<
|
|
|
244
255
|
// Maintenance mode (checked first)
|
|
245
256
|
server.use(maintenanceMiddleware({ projectRoot }));
|
|
246
257
|
|
|
258
|
+
// Request logging into the ring buffer (source for /admin/logs/tail).
|
|
259
|
+
server.use(async (ctx, next) => {
|
|
260
|
+
const start = performance.now();
|
|
261
|
+
const res = ctx.res;
|
|
262
|
+
const originalEnd = res.end.bind(res);
|
|
263
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
264
|
+
(res as any).end = (...args: any[]) => {
|
|
265
|
+
const latencyMs = Math.round(performance.now() - start);
|
|
266
|
+
logBuffer.push({
|
|
267
|
+
ts: Date.now(),
|
|
268
|
+
method: ctx.method,
|
|
269
|
+
path: ctx.path,
|
|
270
|
+
status: res.statusCode,
|
|
271
|
+
latencyMs,
|
|
272
|
+
});
|
|
273
|
+
return originalEnd(...args);
|
|
274
|
+
};
|
|
275
|
+
await next();
|
|
276
|
+
});
|
|
277
|
+
|
|
247
278
|
// User-supplied global middleware
|
|
248
279
|
for (const mw of opts.middleware ?? []) server.use(mw);
|
|
249
280
|
|
package/src/app/index.ts
CHANGED