@anteros/core 0.0.19 → 0.0.21

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.
Files changed (46) hide show
  1. package/README.md +90 -5
  2. package/database/decorator.ts +1 -1
  3. package/database/mongodbadapter.ts +64 -40
  4. package/index.ts +1 -1
  5. package/lib/define.ts +60 -3
  6. package/lib/files.ts +115 -32
  7. package/lib/mcp.ts +59 -0
  8. package/lib/scriptWorker.ts +76 -0
  9. package/lib/scripts.ts +77 -12
  10. package/package.json +6 -3
  11. package/probe-cache.ts +16 -0
  12. package/server/api.ts +35 -28
  13. package/server/boot.ts +78 -16
  14. package/server/cluster.ts +177 -0
  15. package/server/config.ts +0 -1
  16. package/server/env.ts +6 -0
  17. package/server/hono.ts +192 -18
  18. package/server/mcp.ts +232 -0
  19. package/server/metrics.ts +110 -0
  20. package/server/security.ts +13 -9
  21. package/tests/api.test.ts +14 -12
  22. package/tests/cache.test.ts +124 -0
  23. package/tests/crypto.test.ts +101 -0
  24. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  25. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  26. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  27. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  28. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  29. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  30. package/tests/fixtures/metrics-worker.ts +21 -0
  31. package/tests/hono.test.ts +211 -0
  32. package/tests/mcp.test.ts +186 -0
  33. package/tests/metrics-cluster.test.ts +103 -0
  34. package/tests/scripts-heavy.test.ts +49 -0
  35. package/tests/security.test.ts +102 -0
  36. package/tests/ts1011-test.ts +15 -0
  37. package/tests/utils.test.ts +113 -0
  38. package/types/api.d.ts +11 -0
  39. package/types/collection.d.ts +20 -5
  40. package/types/config.d.ts +20 -3
  41. package/types/hook.d.ts +16 -13
  42. package/types/mcp.d.ts +81 -0
  43. package/types/scripts.d.ts +14 -1
  44. package/utils/cache.ts +20 -1
  45. package/utils/crypto.ts +25 -8
  46. package/utils/func.ts +178 -18
package/server/api.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { Hono } from "hono"
1
+ import type { Hono, Context } from "hono"
2
2
  import { cfg, safePublicConfig } from "./config";
3
3
  import type { ApiOptions } from "../types/api";
4
4
  import { getCollection } from "../database/collection";
@@ -244,11 +244,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
244
244
  }
245
245
 
246
246
  // ─── Collection API ─────────────────────────────────────────────────
247
- app.post(API_PREFIX, async (c) => {
247
+ app.post(API_PREFIX, async (c: Context<{ Variables: HonoVariables }>) => {
248
248
  let response: any;
249
249
  let body: any;
250
250
  let rest: InstanceType<typeof useRest>;
251
- let canAccessToAction: boolean | undefined;
252
251
 
253
252
  try {
254
253
  const ContentType = c.req.header('Content-Type');
@@ -274,6 +273,9 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
274
273
  throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
275
274
  }
276
275
 
276
+ // Store tenant in request context so nested useRest() calls (hooks, actions) resolve it
277
+ requestCtxStorage.set('tenant_id', tenant_id);
278
+
277
279
  const col = getCollection(collection, tenant_id)
278
280
  if (!col) {
279
281
  throw new AppError('Collection `' + collection + '` not found', { status: 400, code: 'COLLECTION_NOT_FOUND' });
@@ -344,31 +346,15 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
344
346
  }
345
347
 
346
348
  // ── Access control ──
347
- const access = col?.api?.access as Record<string, boolean | Function> | undefined;
348
-
349
- if (access?.['*'] !== undefined && access?.[action] === undefined) {
350
- if (typeof access['*'] === 'function') {
351
- canAccessToAction = await access['*']({ rest, error: fn.error, jwt: func.jwt, token: accessToken });
352
- } else {
353
- canAccessToAction = access['*'] as boolean;
354
- }
355
- if (canAccessToAction !== true) throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
356
- }
357
-
358
- if (access?.[action] !== undefined) {
359
- canAccessToAction = typeof access[action] === 'function'
360
- ? await (access[action] as Function)({ rest, error: fn.error, jwt: func.jwt, token: accessToken })
361
- : access[action] as boolean;
362
- if (canAccessToAction !== true) throw new AppError('Access denied', { status: 401, code: 'ACCESS_DENIED' });
363
- }
364
-
365
- if (canAccessToAction !== true) {
366
- throw new AppError('Unauthorized', { status: 401, code: 'ACCESS_DENIED_TO_PERFORM_THIS_ACTION' });
367
- }
349
+ // Faille 7: single implementation via evaluateAccess (checks expired/forged tokens)
350
+ await evaluateAccess(col?.api?.access as any, action, rest, 'Action', c);
368
351
 
369
352
  // ── Execute action ──
370
353
  if (col.actions?.[action]) {
371
- response = await col.actions[action]({ rest, data: body?.data, error: fn.error });
354
+ response = await col.actions[action]({
355
+ rest, data: body?.data, error: fn.error,
356
+ io, jwt: func.jwt, token: accessToken,
357
+ });
372
358
  } else {
373
359
  const handler = crudHandlers[action];
374
360
  if (!handler) throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });
@@ -415,6 +401,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
415
401
  const { service, tenant_id } = c.req.param() as { service: string, tenant_id: string };
416
402
  if (!tenant_id) throw new AppError('Tenant ID is required', { status: 400, code: 'TENANT_ID_REQUIRED' });
417
403
  if (!service) throw new AppError('Service is required', { status: 400, code: 'SERVICE_REQUIRED' });
404
+ requestCtxStorage.set('tenant_id', tenant_id);
418
405
 
419
406
  const serviceInstance = cfg.services?.find(s => s.name === service && s._tenant_ === tenant_id) as Service;
420
407
  const { action } = c.req.param() as { action: string };
@@ -457,6 +444,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
457
444
  if (!cfg.tenants.find(t => t.id === tenant_id)) {
458
445
  throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
459
446
  }
447
+ requestCtxStorage.set('tenant_id', tenant_id);
460
448
 
461
449
  const colUpload = getFileCollection(collection, tenant_id);
462
450
  if (!colUpload) {
@@ -469,6 +457,16 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
469
457
  throw new AppError('Content-Type must be multipart/form-data', { code: 'INVALID_CONTENT_TYPE', status: 400 });
470
458
  }
471
459
 
460
+ // Faille 11: reject oversized bodies BEFORE parseBody buffers them in memory
461
+ const contentLength = Number(c.req.header('Content-Length') ?? 0);
462
+ const maxUploadSize = colUpload.upload?.maxSize ?? 10 * 1024 * 1024; // 10MB default
463
+ // multipart overhead (boundaries/headers) ≈ 64KB max
464
+ if (Number.isFinite(contentLength) && contentLength > 0 && contentLength > maxUploadSize + 64 * 1024) {
465
+ throw new AppError(`Request too large (max ${Math.round(maxUploadSize / 1024 / 1024)}MB)`, {
466
+ code: 'FILE_TOO_LARGE', status: 413,
467
+ });
468
+ }
469
+
472
470
  const formData = await c.req.parseBody();
473
471
  const fileField = formData['file'] || formData['upload'];
474
472
  if (!fileField || !(fileField instanceof File)) {
@@ -498,6 +496,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
498
496
  if (!cfg.tenants.find(t => t.id === tenant_id)) {
499
497
  throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
500
498
  }
499
+ requestCtxStorage.set('tenant_id', tenant_id);
501
500
 
502
501
  const colServe = getFileCollection(collection, tenant_id);
503
502
  if (!colServe) {
@@ -505,10 +504,12 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
505
504
  }
506
505
  await checkFileAccess(colServe?.api?.access as any, 'read', tenant_id, c);
507
506
 
508
- const filename = basename(c.req.param('file') as string);
509
- if (!filename || filename.startsWith('.') || filename.includes('..') || filename.includes('/')) {
507
+ // Faille 18: validate the RAW path before basename (checks were dead code)
508
+ const rawFile = c.req.param('file') as string;
509
+ if (!rawFile || rawFile.startsWith('.') || rawFile.includes('..') || rawFile.includes('/') || rawFile.includes('\\')) {
510
510
  throw new AppError('Invalid filename', { status: 400, code: 'INVALID_FILENAME' });
511
511
  }
512
+ const filename = basename(rawFile);
512
513
  const fileId = filename.replace(/\.[^.]+$/, '');
513
514
 
514
515
  const query = c.req.query();
@@ -529,6 +530,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
529
530
  c.header('Content-Type', result.mimetype);
530
531
  if (result.size) c.header('Content-Length', String(result.size));
531
532
  c.header('Cache-Control', 'public, max-age=31536000, immutable');
533
+ // Faille 4: SVG served as attachment (no inline execution)
534
+ if (result.attachment) {
535
+ c.header('Content-Disposition', `attachment; filename="${filename}"`);
536
+ }
532
537
  return c.newResponse(result.stream);
533
538
  } catch (err: any) {
534
539
  if (cfg?.debug) console.error(err)
@@ -544,6 +549,7 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
544
549
  if (!cfg.tenants.find(t => t.id === tenant_id)) {
545
550
  throw new AppError('Tenant `' + tenant_id + '` not found', { status: 400, code: 'TENANT_NOT_FOUND' });
546
551
  }
552
+ requestCtxStorage.set('tenant_id', tenant_id);
547
553
 
548
554
  const colDelete = getFileCollection(collection, tenant_id);
549
555
  if (!colDelete) {
@@ -567,7 +573,8 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
567
573
  // ─── Public config ──────────────────────────────────────────────────
568
574
  app.get('/_dnax/config/:tenant_id', (c) => {
569
575
  const t = c.get('token');
570
- if (!t?.value) {
576
+ // Faille 6: require a VERIFIED, non-expired token (not just any provided value)
577
+ if (!t?.decoded || t?.expired || !t?.provided) {
571
578
  return c.json({ message: 'Authentication required', code: 'AUTH_REQUIRED' }, 401);
572
579
  }
573
580
  const { tenant_id } = c.req.param() as { tenant_id: string };
package/server/boot.ts CHANGED
@@ -16,7 +16,12 @@ import { io, engineIo, websocket } from './io'
16
16
  import { loadServices } from '../lib/services'
17
17
  import { loadSockets } from '../lib/sockets'
18
18
  import { syncWorkflows } from '../lib/workflow'
19
+ import { syncMcpTools } from '../lib/mcp'
19
20
  import { loadTenantsMiddlewares } from '../lib/middleware'
21
+ import crypto from 'node:crypto';
22
+ import os from 'node:os';
23
+ import { registerMetrics, getMetrics, trackRequest } from './metrics';
24
+ import { startMaster, aggregateMetrics } from './cluster';
20
25
 
21
26
  /** Check if a TCP port is already bound */
22
27
  async function isPortInUse(port: number): Promise<boolean> {
@@ -46,6 +51,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
46
51
  await loadServices(); // load services
47
52
  await loadSockets(); // load websocket handlers
48
53
  await syncWorkflows();
54
+ await syncMcpTools(); // load MCP tools per tenant (mcp/**/*.tool.ts)
49
55
  await loadTenantsMiddlewares();
50
56
  loadRoutes(); // load routes
51
57
  //******************************* */
@@ -55,9 +61,8 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
55
61
  const hasAuth = cfg.collections?.some((c: any) => c.api?.auth?.enabled);
56
62
  let jwtSecret = cfg.server.jwt?.secret || Bun.env.JWT_SECRET;
57
63
  if (hasAuth && !jwtSecret) {
58
- jwtSecret = Array.from({ length: 64 }, () =>
59
- 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_'[Math.floor(Math.random() * 64)]
60
- ).join('');
64
+ // CSPRNG: crypto.randomBytes (not Math.random — predictable)
65
+ jwtSecret = crypto.randomBytes(48).toString('base64url');
61
66
  try {
62
67
  const envPath = '.env';
63
68
  const exists = await Bun.file(envPath).exists();
@@ -76,16 +81,51 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
76
81
 
77
82
  const PORT = (cfg.server.port || 4000);
78
83
  const NAME = cfg.server.name || process.env.APP_NAME || 'SERVER';
79
- const useClusterMode = cfg.clusterMode || false;
80
- const env = process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev';
81
- // reusePort: cluster mode ON by default, dev mode OFF to detect zombies
82
- const reusePort = cfg.server.reusePort ?? (useClusterMode && env !== 'dev');
84
+ // Widened to `string`: TS narrows `process.env.NODE_ENV` to a literal union
85
+ // ('development' | 'production' | 'test') and drops the 'dev' fallback branch.
86
+ const env: string = process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev';
87
+ // Bun's SO_REUSEPORT: multiple processes can bind the same port (spawn them yourself)
88
+ const reusePort = cfg.server.reusePort ?? false;
89
+ const isWorker = process.env.BUN_WORKER !== undefined;
90
+
91
+ // ── MASTER (cluster): spawn workers, aggregate metrics, supervise ──
92
+ if (reusePort && !isWorker) {
93
+ const workersCount = cfg.server.workers ?? os.availableParallelism();
94
+ console.log(`[cluster] Master ${process.pid} spawning ${workersCount} workers…`.gray.bold);
95
+
96
+ const master = startMaster({
97
+ workers: workersCount,
98
+ argv: [process.execPath, ...process.argv.slice(1)],
99
+ env: { ...(process.env as Record<string, string>) },
100
+ });
101
+
102
+ // Supervision port: aggregated /health (master does NOT serve the main port)
103
+ const metricsPort = cfg.server.metricsPort ?? PORT + 1;
104
+ const metricsServer = Bun.serve({
105
+ port: metricsPort,
106
+ fetch: (req) => {
107
+ if (new URL(req.url).pathname === '/health') {
108
+ return Response.json({
109
+ status: 'ok',
110
+ master: { pid: process.pid },
111
+ ...aggregateMetrics(master.metrics()),
112
+ });
113
+ }
114
+ return new Response('Not found', { status: 404 });
115
+ },
116
+ });
117
+
118
+ console.log(`[cluster] Aggregated /health → http://localhost:${metricsPort}/health`.gray);
119
+
120
+ // Keep the master alive
121
+ return { master, metricsServer } as any;
122
+ }
83
123
 
84
- // Detect zombie: check if port is already in use in dev mode
124
+ // Detect zombie: check if port is already in use when NOT sharing the port
85
125
  if (!reusePort && env === 'dev') {
86
126
  const occupied = await isPortInUse(PORT);
87
127
  if (occupied) {
88
- console.error(`\n⚠ Port ${PORT} is already in use. Possible zombie server.\n → Kill it: lsof -ti :${PORT} | xargs kill -9\n → Or set server.reusePort: true in config\n`.red.bold);
128
+ console.error(`\n⚠ Port ${PORT} is already in use. Possible zombie server.\n → Kill it: lsof -ti :${PORT} | xargs kill -9\n`.red.bold);
89
129
  process.exit(1);
90
130
  }
91
131
  }
@@ -95,25 +135,47 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
95
135
 
96
136
  const server = Bun.serve({
97
137
  port: PORT,
98
- reusePort,
138
+ reusePort: reusePort || isWorker,
99
139
  fetch: (req, server) => {
100
140
  const url = new URL(req.url);
101
141
 
142
+ // Track every request for the built-in metrics (zero dependency)
143
+ const respond = (p: Response | Promise<Response>) =>
144
+ Promise.resolve(p).then((res) => {
145
+ trackRequest(req, res);
146
+ return res;
147
+ });
148
+
102
149
  if (url.pathname === "/socket.io/") {
103
- return engineIo.handleRequest(req, server);
104
- } else {
105
- return app.fetch(req, server);
150
+ return respond(engineIo.handleRequest(req, server));
106
151
  }
107
-
152
+ return respond(app.fetch(req, server));
108
153
  },
109
154
  websocket: websocket,
110
155
  maxRequestBodySize: 1024 * 1024 * 100, // 100MB
111
156
  })
112
157
 
158
+ // Register server for built-in metrics (GET /health)
159
+ registerMetrics(server);
160
+
161
+ // ── WORKER (cluster): report metrics to the master via IPC ──
162
+ if (isWorker && typeof process.send === 'function') {
163
+ setInterval(() => {
164
+ try {
165
+ process.send!({
166
+ type: 'metrics',
167
+ pid: process.pid,
168
+ metrics: getMetrics(),
169
+ });
170
+ } catch {}
171
+ }, 5000);
172
+ }
173
+
113
174
  let box = '';
114
- box += `${NAME}`.gray.underline + ` (PID: ${process.pid})\n\n`
175
+ const role = isWorker ? 'worker' : (reusePort ? 'master' : 'standalone');
176
+ box += `${NAME}`.gray.underline + ` (PID: ${process.pid}) — ${role}\n\n`
115
177
  box += `Env: ${env || 'dev'}`.green.bold + '\n'
116
- box += `Cluster mode: ${useClusterMode ? 'On'.green.bold : 'Off'.red.bold}\n`.gray.bold
178
+ box += `reusePort: ${(reusePort || isWorker) ? 'On'.green.bold : 'Off'.red.bold}\n`.gray.bold
117
179
  box += `Url: http://localhost:${PORT}`.gray.bold;
118
180
  box += '\n\n';
119
181
  box += `Last boot: 🔄 ${dayjs().format('YYYY-MM-DD HH:mm:ss')}`.gray;
@@ -0,0 +1,177 @@
1
+ import type { Subprocess } from "bun";
2
+ import type { MetricsSnapshot } from "./metrics";
3
+
4
+ // ─── Master/worker cluster over Bun.reusePort + IPC ──────────────────────────
5
+ //
6
+ // Each worker is a full server bound to the same port (SO_REUSEPORT, round-robin
7
+ // by the kernel). The master only supervises workers and aggregates their
8
+ // metrics — it never serves the main HTTP port.
9
+
10
+ export type MasterMetrics = Record<number, MetricsSnapshot>;
11
+
12
+ export type MasterHandle = {
13
+ /** Currently running worker PIDs */
14
+ workers: { pid: number }[];
15
+ /** Latest metrics snapshot per worker PID (may be empty until first report) */
16
+ metrics(): MasterMetrics;
17
+ /** Kill + respawn a specific worker */
18
+ restart(pid: number): void;
19
+ /** Kill all workers */
20
+ shutdown(): void;
21
+ };
22
+
23
+ export type AggregatedMetrics = {
24
+ workers: number;
25
+ uptime: { min: number; max: number };
26
+ requests: {
27
+ total: number;
28
+ active: number;
29
+ errors: number;
30
+ perSecond: number;
31
+ byMethod: Record<string, number>;
32
+ byStatus: Record<string, number>;
33
+ };
34
+ memory: { rss: number; heapUsed: number };
35
+ cpu: { percent: number; cores: number };
36
+ pids: number[];
37
+ };
38
+
39
+ type WorkerEntry = {
40
+ proc: Subprocess;
41
+ metrics: MetricsSnapshot | null;
42
+ restarts: number;
43
+ };
44
+
45
+ const MAX_RESTARTS = 10;
46
+
47
+ export function startMaster(options: {
48
+ workers: number;
49
+ argv: string[];
50
+ env: Record<string, string>;
51
+ }): MasterHandle {
52
+ const { workers, argv, env } = options;
53
+ const entries = new Map<number, WorkerEntry>();
54
+ let stopping = false;
55
+
56
+ const spawn = (): number => {
57
+ const proc = Bun.spawn(argv, {
58
+ env: { ...env, BUN_WORKER: '1' },
59
+ stdout: 'inherit',
60
+ stderr: 'inherit',
61
+ ipc: (message: any) => {
62
+ if (message && typeof message === 'object' && message.type === 'metrics') {
63
+ const entry = entries.get(message.pid as number);
64
+ if (entry && message.metrics) {
65
+ entry.metrics = message.metrics as MetricsSnapshot;
66
+ }
67
+ }
68
+ },
69
+ });
70
+
71
+ entries.set(proc.pid, { proc, metrics: null, restarts: 0 });
72
+
73
+ proc.exited.then((code) => {
74
+ const entry = entries.get(proc.pid);
75
+ if (!entry) return;
76
+ entries.delete(proc.pid);
77
+
78
+ console.log(`[cluster] Worker ${proc.pid} exited (code ${code})`.yellow);
79
+ if (stopping) return;
80
+
81
+ if (entry.restarts >= MAX_RESTARTS) {
82
+ console.error(`[cluster] Worker ${proc.pid} exceeded ${MAX_RESTARTS} restarts — giving up`.red.bold);
83
+ return;
84
+ }
85
+
86
+ // Auto-respawn
87
+ const newPid = spawn();
88
+ const fresh = entries.get(newPid);
89
+ if (fresh) fresh.restarts = entry.restarts + 1;
90
+ console.log(`[cluster] Respawned worker ${proc.pid} → ${newPid} (attempt ${entry.restarts + 1})`.gray);
91
+ });
92
+
93
+ return proc.pid;
94
+ };
95
+
96
+ for (let i = 0; i < workers; i++) {
97
+ spawn();
98
+ }
99
+
100
+ return {
101
+ get workers() {
102
+ return [...entries.keys()].map((pid) => ({ pid }));
103
+ },
104
+
105
+ metrics() {
106
+ const out: MasterMetrics = {};
107
+ for (const [pid, entry] of entries) {
108
+ if (entry.metrics) out[pid] = entry.metrics;
109
+ }
110
+ return out;
111
+ },
112
+
113
+ restart(pid: number) {
114
+ const entry = entries.get(pid);
115
+ if (!entry) return;
116
+ entry.proc.kill();
117
+ // respawn happens in the proc.exited handler
118
+ },
119
+
120
+ shutdown() {
121
+ stopping = true;
122
+ for (const [, entry] of entries) {
123
+ entry.proc.kill();
124
+ }
125
+ entries.clear();
126
+ },
127
+ };
128
+ }
129
+
130
+ /** Merge per-worker snapshots into a single cluster-wide view. */
131
+ export function aggregateMetrics(metrics: MasterMetrics): AggregatedMetrics {
132
+ const snapshots = Object.values(metrics);
133
+ const empty: AggregatedMetrics = {
134
+ workers: 0,
135
+ uptime: { min: 0, max: 0 },
136
+ requests: { total: 0, active: 0, errors: 0, perSecond: 0, byMethod: {}, byStatus: {} },
137
+ memory: { rss: 0, heapUsed: 0 },
138
+ cpu: { percent: 0, cores: 0 },
139
+ pids: [],
140
+ };
141
+
142
+ if (!snapshots.length) return empty;
143
+
144
+ const workers = snapshots.length;
145
+ const maxUptime = Math.max(...snapshots.map((m) => m.uptime));
146
+ const minUptime = Math.min(...snapshots.map((m) => m.uptime));
147
+ const totalReqs = snapshots.reduce((sum, m) => sum + m.requests.total, 0);
148
+
149
+ const byMethod: Record<string, number> = {};
150
+ const byStatus: Record<string, number> = {};
151
+ for (const m of snapshots) {
152
+ for (const [k, v] of Object.entries(m.requests.byMethod)) byMethod[k] = (byMethod[k] ?? 0) + v;
153
+ for (const [k, v] of Object.entries(m.requests.byStatus)) byStatus[k] = (byStatus[k] ?? 0) + v;
154
+ }
155
+
156
+ return {
157
+ workers,
158
+ uptime: { min: minUptime, max: maxUptime },
159
+ requests: {
160
+ total: totalReqs,
161
+ active: snapshots.reduce((sum, m) => sum + m.pendingRequests, 0),
162
+ errors: snapshots.reduce((sum, m) => sum + m.requests.errors, 0),
163
+ perSecond: maxUptime > 0 ? Math.round(totalReqs / maxUptime) : 0,
164
+ byMethod,
165
+ byStatus,
166
+ },
167
+ memory: {
168
+ rss: snapshots.reduce((sum, m) => sum + m.memory.rss, 0),
169
+ heapUsed: snapshots.reduce((sum, m) => sum + m.memory.heapUsed, 0),
170
+ },
171
+ cpu: {
172
+ percent: Math.round((snapshots.reduce((sum, m) => sum + m.cpu.percent, 0) / workers) * 10) / 10,
173
+ cores: snapshots[0]?.cpu.cores ?? 0,
174
+ },
175
+ pids: snapshots.map((m) => m.pid),
176
+ };
177
+ }
package/server/config.ts CHANGED
@@ -15,7 +15,6 @@ const cfg: Config = { // app Config
15
15
 
16
16
  function formatConfig(config: ServerConfig) { // format the config
17
17
 
18
- cfg.clusterMode = config.clusterMode ?? true;
19
18
  cfg.server = {
20
19
  ...cfg.server,
21
20
  ...config.server,
package/server/env.ts CHANGED
@@ -1,6 +1,8 @@
1
1
  /**
2
2
  * Hono context variables shared by middleware and route handlers.
3
3
  */
4
+ import type { ConnInfo } from "hono/conninfo";
5
+
4
6
  export type HonoVariables = {
5
7
  /** Present when `Authorization: Bearer <jwt>` was sent and verification succeeded. */
6
8
  token?: {
@@ -13,4 +15,8 @@ export type HonoVariables = {
13
15
  /** Whether the token is expired */
14
16
  expired: boolean;
15
17
  };
18
+ /** Best-effort client IP, resolved once per request (real socket, or trusted proxy header when cfg.server.trustProxy). */
19
+ clientIp?: string;
20
+ /** Connection info (real socket address) — set once per request by the IP middleware. */
21
+ connInfo?: ConnInfo;
16
22
  };