@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.
- package/README.md +90 -5
- package/database/decorator.ts +1 -1
- package/database/mongodbadapter.ts +64 -40
- package/index.ts +1 -1
- package/lib/define.ts +60 -3
- package/lib/files.ts +115 -32
- package/lib/mcp.ts +59 -0
- package/lib/scriptWorker.ts +76 -0
- package/lib/scripts.ts +77 -12
- package/package.json +6 -3
- package/probe-cache.ts +16 -0
- package/server/api.ts +35 -28
- package/server/boot.ts +78 -16
- package/server/cluster.ts +177 -0
- package/server/config.ts +0 -1
- package/server/env.ts +6 -0
- package/server/hono.ts +192 -18
- package/server/mcp.ts +232 -0
- package/server/metrics.ts +110 -0
- package/server/security.ts +13 -9
- package/tests/api.test.ts +14 -12
- package/tests/cache.test.ts +124 -0
- package/tests/crypto.test.ts +101 -0
- package/tests/fixtures/heavy/collections/items.model.ts +8 -0
- package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
- package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
- package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
- package/tests/fixtures/metrics-worker.ts +21 -0
- package/tests/hono.test.ts +211 -0
- package/tests/mcp.test.ts +186 -0
- package/tests/metrics-cluster.test.ts +103 -0
- package/tests/scripts-heavy.test.ts +49 -0
- package/tests/security.test.ts +102 -0
- package/tests/ts1011-test.ts +15 -0
- package/tests/utils.test.ts +113 -0
- package/types/api.d.ts +11 -0
- package/types/collection.d.ts +20 -5
- package/types/config.d.ts +20 -3
- package/types/hook.d.ts +16 -13
- package/types/mcp.d.ts +81 -0
- package/types/scripts.d.ts +14 -1
- package/utils/cache.ts +20 -1
- package/utils/crypto.ts +25 -8
- 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
|
-
|
|
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]({
|
|
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
|
-
|
|
509
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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 += `
|
|
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
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
|
};
|