@anteros/core 0.0.20 → 0.0.22
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/collection.ts +1 -1
- package/database/mongodbadapter.ts +3 -0
- package/index.ts +1 -1
- package/lib/define.ts +60 -3
- package/lib/files.ts +15 -27
- package/lib/mcp.ts +59 -0
- package/lib/scriptWorker.ts +76 -0
- package/lib/scripts.ts +77 -12
- package/package.json +5 -2
- package/probe-cache.ts +16 -0
- package/server/api.ts +4 -1
- package/server/boot.ts +75 -13
- package/server/cluster.ts +177 -0
- package/server/config.ts +0 -1
- package/server/env.ts +6 -0
- package/server/hono.ts +185 -14
- package/server/mcp.ts +232 -0
- package/server/metrics.ts +110 -0
- package/server/security.ts +13 -9
- 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/utils.test.ts +113 -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 +12 -4
package/server/boot.ts
CHANGED
|
@@ -16,8 +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'
|
|
20
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';
|
|
21
25
|
|
|
22
26
|
/** Check if a TCP port is already bound */
|
|
23
27
|
async function isPortInUse(port: number): Promise<boolean> {
|
|
@@ -47,6 +51,7 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
|
|
|
47
51
|
await loadServices(); // load services
|
|
48
52
|
await loadSockets(); // load websocket handlers
|
|
49
53
|
await syncWorkflows();
|
|
54
|
+
await syncMcpTools(); // load MCP tools per tenant (mcp/**/*.tool.ts)
|
|
50
55
|
await loadTenantsMiddlewares();
|
|
51
56
|
loadRoutes(); // load routes
|
|
52
57
|
//******************************* */
|
|
@@ -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
|
};
|
package/server/hono.ts
CHANGED
|
@@ -1,22 +1,111 @@
|
|
|
1
|
-
import { Hono } from "hono";
|
|
1
|
+
import { Hono, type Context } from "hono";
|
|
2
2
|
import { cors } from "hono/cors"
|
|
3
3
|
import { compress } from 'hono/compress'
|
|
4
4
|
import { ipRestriction } from "hono/ip-restriction"
|
|
5
5
|
import { bodyLimit } from "hono/body-limit"
|
|
6
6
|
import { secureHeaders } from "hono/secure-headers"
|
|
7
|
+
import { HTTPException } from "hono/http-exception"
|
|
7
8
|
import { getConnInfo } from "hono/bun"
|
|
9
|
+
import type { ConnInfo, GetConnInfo } from "hono/conninfo"
|
|
10
|
+
import { Redis } from "ioredis"
|
|
8
11
|
import { cfg } from "./config"
|
|
9
|
-
import { rateLimit } from "./security"
|
|
12
|
+
import { rateLimit, createRedisStore, createMemoryStore, type RateLimitStore } from "./security"
|
|
10
13
|
import { initializeRoutes } from "./routes";
|
|
11
|
-
import {
|
|
14
|
+
import { asyncContextStorage, requestCtxStorage } from "../lib/asyncContextStorage";
|
|
12
15
|
import { initializeApi } from "./api";
|
|
16
|
+
import { initializeMcp } from "./mcp";
|
|
13
17
|
import { getGlobalMiddlewares } from "../lib/middleware";
|
|
14
18
|
import { jwt } from "../utils/func";
|
|
15
19
|
import { AppError } from "../lib/error";
|
|
20
|
+
import { getMetrics } from "./metrics";
|
|
16
21
|
import type { HonoVariables } from "./env";
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
// ─── Redis-backed rate limiting (C3) ─────────────────────────────────────────
|
|
24
|
+
// Lazy singleton: created once per process and reused across createApp() calls.
|
|
25
|
+
// Each increment falls back to the in-memory store if Redis is unavailable,
|
|
26
|
+
// so the limiter keeps working during a Redis outage.
|
|
27
|
+
let redisRateLimitStore: RateLimitStore | null | undefined;
|
|
28
|
+
let redisRateLimitSignature: string | undefined;
|
|
29
|
+
|
|
30
|
+
function getRedisRateLimitStore(): RateLimitStore | null {
|
|
31
|
+
const rl = cfg.server.rateLimit;
|
|
32
|
+
// Re-evaluate only when the rate-limit config or Redis env vars change,
|
|
33
|
+
// so multiple createApp() calls (tests, hot reload) reuse the same client.
|
|
34
|
+
const signature = JSON.stringify([
|
|
35
|
+
rl?.useRedis,
|
|
36
|
+
rl?.redis,
|
|
37
|
+
process.env.REDIS_URL,
|
|
38
|
+
process.env.REDIS_HOST,
|
|
39
|
+
process.env.REDIS_PORT,
|
|
40
|
+
process.env.REDIS_PASSWORD,
|
|
41
|
+
]);
|
|
42
|
+
if (redisRateLimitSignature === signature && redisRateLimitStore !== undefined) return redisRateLimitStore;
|
|
43
|
+
redisRateLimitSignature = signature;
|
|
44
|
+
|
|
45
|
+
if (!rl) {
|
|
46
|
+
redisRateLimitStore = null;
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Redis is used AUTOMATICALLY when connection info is present (rateLimit.redis
|
|
51
|
+
// block or REDIS_* env vars) — no need for useRedis: true.
|
|
52
|
+
// - useRedis: false → force the in-memory store
|
|
53
|
+
// - useRedis: true → force Redis even without connection info (localhost)
|
|
54
|
+
// - unset → auto: Redis if configured, else memory
|
|
55
|
+
const conn = rl.redis ?? {};
|
|
56
|
+
const hasRedisConfig =
|
|
57
|
+
Boolean(conn.url || conn.host || conn.port || conn.password) ||
|
|
58
|
+
Boolean(process.env.REDIS_URL || process.env.REDIS_HOST || process.env.REDIS_PORT || process.env.REDIS_PASSWORD);
|
|
59
|
+
const useRedis = rl.useRedis === true || (rl.useRedis !== false && hasRedisConfig);
|
|
60
|
+
|
|
61
|
+
if (!useRedis) {
|
|
62
|
+
redisRateLimitStore = null;
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const conn = rl.redis ?? {};
|
|
68
|
+
// Stop reconnecting after a few attempts: a dead Redis must not spam logs
|
|
69
|
+
// or keep the process alive — the limiter falls back to memory meanwhile.
|
|
70
|
+
const retryStrategy = (times: number) => (times > 5 ? null : Math.min(times * 500, 3000));
|
|
71
|
+
const client = conn.url
|
|
72
|
+
? new Redis(conn.url, { retryStrategy, maxRetriesPerRequest: 2 })
|
|
73
|
+
: new Redis({
|
|
74
|
+
host: conn.host ?? process.env.REDIS_HOST ?? 'localhost',
|
|
75
|
+
port: conn.port ?? (Number(process.env.REDIS_PORT) || 6379),
|
|
76
|
+
password: conn.password ?? process.env.REDIS_PASSWORD,
|
|
77
|
+
retryStrategy,
|
|
78
|
+
maxRetriesPerRequest: 2,
|
|
79
|
+
});
|
|
80
|
+
let errorLogged = false;
|
|
81
|
+
client.on('error', (err) => {
|
|
82
|
+
if (errorLogged) return;
|
|
83
|
+
errorLogged = true;
|
|
84
|
+
console.error('[rateLimit] Redis connection error:', err?.message);
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
const redisStore = createRedisStore(client);
|
|
88
|
+
const memoryFallback = createMemoryStore();
|
|
89
|
+
redisRateLimitStore = {
|
|
90
|
+
async increment(key: string, windowMs: number) {
|
|
91
|
+
try {
|
|
92
|
+
return await redisStore.increment(key, windowMs);
|
|
93
|
+
} catch (err: any) {
|
|
94
|
+
console.error('[rateLimit] Redis store failed, falling back to in-memory:', err?.message);
|
|
95
|
+
return memoryFallback.increment(key, windowMs);
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
};
|
|
99
|
+
console.log('[rateLimit] store: Redis (with in-memory fallback)');
|
|
100
|
+
} catch (err: any) {
|
|
101
|
+
console.error('[rateLimit] Redis init failed, using in-memory store:', err?.message);
|
|
102
|
+
redisRateLimitStore = null;
|
|
103
|
+
}
|
|
104
|
+
return redisRateLimitStore;
|
|
105
|
+
}
|
|
106
|
+
|
|
19
107
|
function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
108
|
+
const app = new Hono<{ Variables: HonoVariables }>();
|
|
20
109
|
const allowHeaders = [
|
|
21
110
|
"Tenant-Id",
|
|
22
111
|
"Content-Type",
|
|
@@ -43,17 +132,78 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
43
132
|
"HEAD",
|
|
44
133
|
...(cfg.server.cors?.allowMethods as string[] || []),
|
|
45
134
|
]
|
|
46
|
-
// cors
|
|
47
|
-
const
|
|
48
|
-
const corsCredentials = cfg.server.cors?.credentials
|
|
135
|
+
// cors — origin accepts string | string[] | function (see ServerConfig type)
|
|
136
|
+
const corsConfig = cfg.server.cors?.origin;
|
|
137
|
+
const corsCredentials = cfg.server.cors?.credentials ?? false;
|
|
138
|
+
|
|
139
|
+
// Detect a wildcard in any supported form without breaking on string/function origins
|
|
140
|
+
const hasWildcardOrigin =
|
|
141
|
+
corsConfig === '*' ||
|
|
142
|
+
(Array.isArray(corsConfig) && corsConfig.includes('*'));
|
|
49
143
|
|
|
50
|
-
if (corsCredentials && (!
|
|
144
|
+
if (corsCredentials && (!corsConfig || hasWildcardOrigin)) {
|
|
51
145
|
throw new Error(
|
|
52
146
|
'CORS misconfiguration: credentials: true is incompatible with origin: *. '
|
|
53
147
|
+ 'Set explicit origins in cfg.server.cors.origin.'
|
|
54
148
|
);
|
|
55
149
|
}
|
|
56
150
|
|
|
151
|
+
// Config function form: (ctx: { origin, c }) => string | string[]
|
|
152
|
+
// Hono expects: (origin, c) => string | Promise<string | null | undefined> | null | undefined
|
|
153
|
+
type CorsOriginOption = NonNullable<Parameters<typeof cors>[0]>['origin'];
|
|
154
|
+
const corsOrigin =
|
|
155
|
+
typeof corsConfig === 'function'
|
|
156
|
+
? ((origin: string, c: Context) => corsConfig({ origin, c })) as unknown as CorsOriginOption
|
|
157
|
+
: corsConfig;
|
|
158
|
+
|
|
159
|
+
// Global error handler — maps AppError thrown by user routes, global
|
|
160
|
+
// middlewares and useRest to their proper status/code/message.
|
|
161
|
+
// (API routes keep their own errorResponse() for backward compat.)
|
|
162
|
+
app.onError((err, c) => {
|
|
163
|
+
// Let Hono's HTTPException respond with its own status (401/413/…)
|
|
164
|
+
if (err instanceof HTTPException) {
|
|
165
|
+
return err.getResponse();
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const isAppError = err instanceof AppError;
|
|
169
|
+
// Always log unexpected errors (default Hono behavior preserved)
|
|
170
|
+
if (!isAppError) console.error(err);
|
|
171
|
+
|
|
172
|
+
const status = isAppError ? Number(err.status) || 500 : 500;
|
|
173
|
+
return c.json({
|
|
174
|
+
message: isAppError ? err.message : 'Internal server error',
|
|
175
|
+
code: isAppError ? err.code : 'INTERNAL_SERVER_ERROR',
|
|
176
|
+
meta: isAppError ? err.meta : undefined,
|
|
177
|
+
}, status as any);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// ── Client IP: resolved once per request and shared by all middlewares ──
|
|
181
|
+
// getConnInfo(c) = server.requestIP() → the real socket address. Proxy headers
|
|
182
|
+
// (CF-Connecting-IP / X-Forwarded-For) are ONLY trusted when cfg.server.trustProxy
|
|
183
|
+
// is explicitly enabled, otherwise they are spoofable.
|
|
184
|
+
// getConnInfo throws when the app runs outside a real Bun.serve (e.g. app.request()
|
|
185
|
+
// in tests) → fall back to an empty address.
|
|
186
|
+
const safeGetConnInfo = (c: Context): ConnInfo => {
|
|
187
|
+
try {
|
|
188
|
+
return getConnInfo(c);
|
|
189
|
+
} catch {
|
|
190
|
+
return { remote: {} };
|
|
191
|
+
}
|
|
192
|
+
};
|
|
193
|
+
|
|
194
|
+
app.use(async (c, next) => {
|
|
195
|
+
const connInfo = safeGetConnInfo(c);
|
|
196
|
+
c.set('connInfo', connInfo);
|
|
197
|
+
c.set('clientIp', cfg.server.trustProxy
|
|
198
|
+
? c.req.header('CF-Connecting-IP') ?? c.req.header('X-Forwarded-For') ?? connInfo.remote.address ?? 'unknown'
|
|
199
|
+
: connInfo.remote.address ?? 'unknown');
|
|
200
|
+
await next();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
// Reuse the cached connInfo instead of re-querying the socket per middleware
|
|
204
|
+
const cachedGetConnInfo: GetConnInfo = (c) =>
|
|
205
|
+
(c as Context<{ Variables: HonoVariables }>).get('connInfo') ?? safeGetConnInfo(c);
|
|
206
|
+
|
|
57
207
|
|
|
58
208
|
|
|
59
209
|
app.use(cors({
|
|
@@ -67,7 +217,7 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
67
217
|
app.use(compress());
|
|
68
218
|
|
|
69
219
|
// ip restriction
|
|
70
|
-
app.use(ipRestriction(
|
|
220
|
+
app.use(ipRestriction(cachedGetConnInfo, {
|
|
71
221
|
denyList: cfg.server?.ipRestriction?.denyList || [],
|
|
72
222
|
allowList: cfg.server?.ipRestriction?.allowList || [],
|
|
73
223
|
}))
|
|
@@ -88,19 +238,38 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
88
238
|
maxSize: cfg.server.body?.maxSize ?? 1024 * 1024 * 100, // 100MB
|
|
89
239
|
}));
|
|
90
240
|
|
|
241
|
+
// ── Health & metrics (built-in, zero dependency) ──
|
|
242
|
+
app.get('/health', (c) => {
|
|
243
|
+
return c.json({
|
|
244
|
+
...getMetrics(),
|
|
245
|
+
ip: c.get('clientIp') ?? 'unknown',
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
|
|
91
249
|
|
|
92
250
|
|
|
93
251
|
// rate limiting (global)
|
|
94
252
|
if (cfg.server.rateLimit?.enabled !== false) {
|
|
253
|
+
// Redis-backed store when configured (C3), in-memory otherwise
|
|
254
|
+
const store = getRedisRateLimitStore() ?? undefined;
|
|
255
|
+
|
|
256
|
+
// Distinct keys so the stricter login limit never shares counters with the global one
|
|
257
|
+
const keyByClientIp = (prefix: string) => (c: Context) =>
|
|
258
|
+
`rn:rl:${prefix}${(c as any).get('clientIp') ?? 'unknown'}`;
|
|
259
|
+
|
|
95
260
|
app.use('*', rateLimit({
|
|
96
261
|
windowMs: cfg.server.rateLimit?.windowMs ?? 60_000,
|
|
97
262
|
max: cfg.server.rateLimit?.max ?? 100,
|
|
263
|
+
store,
|
|
264
|
+
keyGenerator: keyByClientIp(''),
|
|
98
265
|
}));
|
|
99
266
|
|
|
100
267
|
// stricter limit for login endpoints
|
|
101
268
|
app.use('/api/*/login', rateLimit({
|
|
102
269
|
windowMs: cfg.server.rateLimit?.login?.windowMs ?? 60_000,
|
|
103
270
|
max: cfg.server.rateLimit?.login?.max ?? 10,
|
|
271
|
+
store,
|
|
272
|
+
keyGenerator: keyByClientIp('login:'),
|
|
104
273
|
}));
|
|
105
274
|
}
|
|
106
275
|
|
|
@@ -108,7 +277,6 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
108
277
|
app.use(async (c, next) => {
|
|
109
278
|
return asyncContextStorage.run(new Map(), async () => {
|
|
110
279
|
const traceId = crypto.randomUUID();
|
|
111
|
-
const connInfo = getConnInfo(c);
|
|
112
280
|
requestCtxStorage.set('trace', { id: traceId });
|
|
113
281
|
requestCtxStorage.set('internal', false);
|
|
114
282
|
|
|
@@ -120,7 +288,7 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
120
288
|
delete requestCtx.cookie;
|
|
121
289
|
requestCtxStorage.set('meta', {
|
|
122
290
|
request: {
|
|
123
|
-
ip:
|
|
291
|
+
ip: c.get('clientIp') ?? 'unknown',
|
|
124
292
|
user_agent: c.req.header('User-Agent') ?? '',
|
|
125
293
|
headers: requestCtx,
|
|
126
294
|
method: c.req.method,
|
|
@@ -140,7 +308,8 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
140
308
|
value: isValid ? bearer : null,
|
|
141
309
|
decoded: isValid ? (value as Record<string, unknown>) : null,
|
|
142
310
|
provided: true,
|
|
143
|
-
|
|
311
|
+
// jwt.verify already flags expiry (expired: true, value: null)
|
|
312
|
+
expired,
|
|
144
313
|
};
|
|
145
314
|
|
|
146
315
|
requestCtxStorage.set('token', tokenData);
|
|
@@ -166,6 +335,9 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
166
335
|
// initialize api
|
|
167
336
|
initializeApi(app);
|
|
168
337
|
|
|
338
|
+
// initialize MCP (tools served at /mcp/:tenant_id)
|
|
339
|
+
initializeMcp(app);
|
|
340
|
+
|
|
169
341
|
|
|
170
342
|
return app;
|
|
171
343
|
}
|
|
@@ -174,6 +346,5 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
|
|
|
174
346
|
|
|
175
347
|
|
|
176
348
|
export {
|
|
177
|
-
createApp
|
|
178
|
-
app
|
|
349
|
+
createApp
|
|
179
350
|
}
|