@anteros/core 0.0.24 → 0.0.26

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/index.ts CHANGED
@@ -13,6 +13,7 @@ import * as crypto from "./utils/crypto";
13
13
 
14
14
  // BentoCache-based caching (memory L1 + filesystem/Redis L2)
15
15
  import { useMemoryCache, useFilesystemCache, useRedisCache } from "./utils/cache";
16
+ import { logger } from "./utils/logger";
16
17
  const cache = {
17
18
  useMemoryCache,
18
19
  useFilesystemCache,
@@ -31,4 +32,5 @@ export {
31
32
  utils,
32
33
  cache,
33
34
  crypto,
35
+ logger,
34
36
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.24",
4
+ "version": "0.0.26",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
@@ -10,7 +10,8 @@
10
10
  "bun": ">=1.3.0"
11
11
  },
12
12
  "devDependencies": {
13
- "@types/bun": "^1.3.14"
13
+ "@types/bun": "^1.3.14",
14
+ "@types/tcp-port-used": "^1.0.4"
14
15
  },
15
16
  "peerDependencies": {
16
17
  "typescript": "6.0.3"
@@ -29,6 +30,7 @@
29
30
  "jose": "6.2.8",
30
31
  "mongodb": "7.5.0",
31
32
  "socket.io": "4.8.3",
33
+ "tcp-port-used": "^1.0.3",
32
34
  "ufo": "1.6.4",
33
35
  "zod": "^4.4.3"
34
36
  }
package/server/api.ts CHANGED
@@ -174,7 +174,7 @@ type CrudHandler = (rest: InstanceType<typeof useRest>, collection: string, body
174
174
 
175
175
  const crudHandlers: Record<string, CrudHandler> = {
176
176
  aggregate: (rest, collection, body) => rest.aggregate(collection, body?.pipeline || []),
177
- find: (rest, collection, body) => rest.find(collection, body?.params || {}),
177
+ find: (rest, collection, body) => rest.find(collection, body?.params || {}, { useCache: body?.options?.useCache }),
178
178
  findOne: (rest, collection, body) => rest.findOne(collection, body?.id, body?.params || {}),
179
179
  insertOne: (rest, collection, body) => rest.insertOne(collection, body?.data),
180
180
  insertMany: (rest, collection, body) => rest.insertMany(collection, body?.data),
package/server/boot.ts CHANGED
@@ -1,5 +1,5 @@
1
1
 
2
- import { createApp } from './hono'
2
+ import { createApp, collectHeaders } from './hono'
3
3
  import type { ServerConfig } from '../types/config'
4
4
  import dayjs from 'dayjs';
5
5
  import pkg from '../package.json';
@@ -22,18 +22,8 @@ import crypto from 'node:crypto';
22
22
  import os from 'node:os';
23
23
  import { registerMetrics, getMetrics, trackRequest } from './metrics';
24
24
  import { startMaster, aggregateMetrics } from './cluster';
25
-
26
- /** Check if a TCP port is already bound */
27
- async function isPortInUse(port: number): Promise<boolean> {
28
- try {
29
- const proc = Bun.spawn(['lsof', '-ti', `:${port}`], { stdout: 'pipe' });
30
- const output = await new Response(proc.stdout).text();
31
- await proc.exited;
32
- return output.trim().length > 0;
33
- } catch {
34
- return false;
35
- }
36
- }
25
+ import tcpPortUsed from 'tcp-port-used';
26
+ import { logger } from '../utils/logger';
37
27
 
38
28
  type BootAppOptions = ServerConfig & {
39
29
 
@@ -45,6 +35,15 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
45
35
  // Load required resources
46
36
  //******************************* */
47
37
  options = formatConfig(options);
38
+
39
+ // Logging: configure from cfg.server.logging (console + file, e.g. logs/anteros.log)
40
+ logger.configure(cfg.server.logging);
41
+ logger.info('Starting Anteros server', {
42
+ name: cfg.server.name ?? process.env.APP_NAME ?? 'SERVER',
43
+ port: cfg.server.port ?? 4000,
44
+ env: process.env.NODE_ENV || Bun.env.NODE_ENV || 'dev',
45
+ tenants: cfg.tenants?.map((t) => t.id),
46
+ });
48
47
  await syncTenants(); // sync tenants and connect to database
49
48
  await syncCollections(); // sync collections and create collections on database
50
49
  await syncFileCollections(); // sync file collections
@@ -117,14 +116,38 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
117
116
 
118
117
  console.log(`[cluster] Aggregated /health → http://localhost:${metricsPort}/health`.gray);
119
118
 
119
+ // Graceful shutdown: stop workers + metrics server so a restart
120
+ // (e.g. bun --watch) never leaves the port occupied
121
+ const shutdownCluster = (signal: string) => {
122
+ console.log(`\n${signal} received — shutting down workers…`.gray);
123
+ try { master.shutdown(); } catch {}
124
+ try { metricsServer.stop(true); } catch {}
125
+ for (const tenant of cfg.tenants ?? []) {
126
+ try { tenant.database?.client?.close(); } catch {}
127
+ }
128
+ process.exit(0);
129
+ };
130
+ process.once('SIGINT', () => shutdownCluster('SIGINT'));
131
+ process.once('SIGTERM', () => shutdownCluster('SIGTERM'));
132
+
120
133
  // Keep the master alive
121
134
  return { master, metricsServer } as any;
122
135
  }
123
136
 
124
- // Detect zombie: check if port is already in use when NOT sharing the port
137
+ // Detect zombie: check if port is already in use when NOT sharing the port.
138
+ // tcp-port-used probes with a real TCP connection — only a listening server
139
+ // counts (a connected client never triggers a false positive) and it works
140
+ // on any platform (no lsof dependency). Grace period: after a restart the
141
+ // previous process may still be releasing the port (Mongo / Socket.IO
142
+ // shutdown) — wait before declaring a zombie.
125
143
  if (!reusePort && env === 'dev') {
126
- const occupied = await isPortInUse(PORT);
127
- if (occupied) {
144
+ if (await tcpPortUsed.check(PORT)) {
145
+ console.log(`⏳ Port ${PORT} is still held, waiting for the previous process to release it (~2.5s)…`.gray);
146
+ }
147
+ try {
148
+ // ~2.5s grace (5 × 500ms); rejects if the port stays occupied
149
+ await tcpPortUsed.waitUntilFree(PORT, 500, 2500);
150
+ } catch {
128
151
  console.error(`\n⚠ Port ${PORT} is already in use. Possible zombie server.\n → Kill it: lsof -ti :${PORT} | xargs kill -9\n`.red.bold);
129
152
  process.exit(1);
130
153
  }
@@ -147,7 +170,39 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
147
170
  });
148
171
 
149
172
  if (url.pathname === "/socket.io/") {
150
- return respond(engineIo.handleRequest(req, server));
173
+ const start = performance.now();
174
+ return respond(engineIo.handleRequest(req, server)).then((res) => {
175
+ // Access log for the Socket.IO upgrade path (handled outside Hono) —
176
+ // same Caddy-compatible structure as the API access log
177
+ const remote: any = (server as any).requestIP?.(req);
178
+ const remoteIp = remote?.address ?? null;
179
+ const remotePort = remote?.port ?? null;
180
+ logger.info(`${req.method} ${url.pathname}${url.search} → ${res.status}`, {
181
+ logger: 'http.log.access',
182
+ status: res.status,
183
+ duration: Math.round((performance.now() - start) * 10) / 10,
184
+ method: req.method,
185
+ uri: url.pathname + url.search,
186
+ remote_ip: remoteIp,
187
+ bytes_read: Number(req.headers.get('content-length')) || 0,
188
+ size: Number(res.headers.get('content-length')) || 0,
189
+ user_id: '',
190
+ ip: remoteIp ?? 'unknown',
191
+ traceId: null,
192
+ tenant: null,
193
+ request: {
194
+ headers: collectHeaders(req.headers),
195
+ client_ip: remoteIp ?? 'unknown',
196
+ host: req.headers.get('host') ?? '',
197
+ method: req.method,
198
+ remote_ip: remoteIp,
199
+ remote_port: remotePort,
200
+ uri: url.pathname + url.search,
201
+ },
202
+ resp_headers: collectHeaders(res.headers),
203
+ });
204
+ return res;
205
+ });
151
206
  }
152
207
  return respond(app.fetch(req, server));
153
208
  },
@@ -158,6 +213,19 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
158
213
  // Register server for built-in metrics (GET /health)
159
214
  registerMetrics(server);
160
215
 
216
+ // Graceful shutdown: release the port & DB connections so restarts
217
+ // (e.g. bun --watch) never hit a zombie / EADDRINUSE
218
+ const shutdownServer = (signal: string) => {
219
+ console.log(`\n${signal} received — shutting down…`.gray);
220
+ try { server.stop(true); } catch {}
221
+ for (const tenant of cfg.tenants ?? []) {
222
+ try { tenant.database?.client?.close(); } catch {}
223
+ }
224
+ process.exit(0);
225
+ };
226
+ process.once('SIGINT', () => shutdownServer('SIGINT'));
227
+ process.once('SIGTERM', () => shutdownServer('SIGTERM'));
228
+
161
229
  // ── WORKER (cluster): report metrics to the master via IPC ──
162
230
  if (isWorker && typeof process.send === 'function') {
163
231
  setInterval(() => {
@@ -198,8 +266,29 @@ async function bootApp(options: BootAppOptions = {} as BootAppOptions) {
198
266
 
199
267
  }, 150);
200
268
 
269
+ // Boot event — recorded in the log file only (the console banner already
270
+ // shows name / PID / role / URL, so this line would be redundant on screen)
271
+ logger.file('Server started', {
272
+ name: NAME,
273
+ pid: process.pid,
274
+ role,
275
+ url: `http://localhost:${PORT}`,
276
+ env: env || 'dev',
277
+ logFile: logger.filePath || undefined,
278
+ });
279
+
280
+ // Uncaught errors → log file (MongoDB-style), then exit
281
+ process.on('uncaughtException', (err) => {
282
+ logger.error('Uncaught exception', { message: err?.message, stack: err?.stack });
283
+ process.exit(1);
284
+ });
285
+ process.on('unhandledRejection', (reason: any) => {
286
+ logger.error('Unhandled rejection', { message: reason?.message ?? String(reason), stack: reason?.stack });
287
+ });
288
+
201
289
  return server;
202
290
  } catch (err: any) {
291
+ logger.error('Failed to boot server', cfg.debug ? { error: err, message: err?.message } : { message: err?.message });
203
292
  console.error('Failed to boot server', cfg.debug ? err : err?.message);
204
293
  process.exit(1);
205
294
  }
package/server/hono.ts CHANGED
@@ -18,8 +18,24 @@ import { getGlobalMiddlewares } from "../lib/middleware";
18
18
  import { jwt } from "../utils/func";
19
19
  import { AppError } from "../lib/error";
20
20
  import { getMetrics } from "./metrics";
21
+ import { logger } from "../utils/logger";
21
22
  import type { HonoVariables } from "./env";
22
23
 
24
+ /**
25
+ * Caddy-style header collection (array values); sensitive headers stripped
26
+ * (`authorization`, `cookie`, `proxy-authorization`, `set-cookie`).
27
+ */
28
+ export function collectHeaders(h: Headers): Record<string, string[]> {
29
+ const EXCLUDE = ['authorization', 'cookie', 'proxy-authorization', 'set-cookie'];
30
+ const out: Record<string, string[]> = {};
31
+ for (const [name, value] of h.entries()) {
32
+ const key = name.toLowerCase();
33
+ if (EXCLUDE.includes(key)) continue;
34
+ out[key] = [value];
35
+ }
36
+ return out;
37
+ }
38
+
23
39
  // ─── Redis-backed rate limiting (C3) ─────────────────────────────────────────
24
40
  // Lazy singleton: created once per process and reused across createApp() calls.
25
41
  // Each increment falls back to the in-memory store if Redis is unavailable,
@@ -200,6 +216,51 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
200
216
  await next();
201
217
  });
202
218
 
219
+ // Access log — registered before /health & routes so EVERY request is logged.
220
+ // Runs around the async-context middleware, so traceId/tenant are readable here.
221
+ // Caddy-compatible JSONL payload: `request`/`resp_headers` blocks + flat fields.
222
+ app.use(async (c, next) => {
223
+ const start = performance.now();
224
+ try {
225
+ await next();
226
+ } finally {
227
+ const duration = performance.now() - start;
228
+ const trace = requestCtxStorage.get<{ id: string }>('trace');
229
+ const conn = c.get('connInfo');
230
+ const token = c.get('token');
231
+ const url = new URL(c.req.raw.url);
232
+ const uri = url.pathname + url.search;
233
+ const ip = c.get('clientIp') ?? 'unknown';
234
+ const remoteIp = conn?.remote?.address ?? null;
235
+ const remotePort = conn?.remote?.port ?? null;
236
+
237
+ logger.info(`${c.req.method} ${uri} → ${c.res.status}`, {
238
+ logger: 'http.log.access',
239
+ status: c.res.status,
240
+ duration: Math.round(duration * 10) / 10,
241
+ method: c.req.method,
242
+ uri,
243
+ remote_ip: remoteIp,
244
+ bytes_read: Number(c.req.header('content-length')) || 0,
245
+ size: Number(c.res.headers.get('content-length')) || 0,
246
+ user_id: (token?.decoded as Record<string, unknown> | null)?.['sub'] as string ?? '',
247
+ ip,
248
+ traceId: trace?.id ?? crypto.randomUUID(),
249
+ tenant: requestCtxStorage.get<string>('tenant_id') ?? null,
250
+ request: {
251
+ headers: collectHeaders(c.req.raw.headers),
252
+ client_ip: ip,
253
+ host: c.req.header('host') ?? '',
254
+ method: c.req.method,
255
+ remote_ip: remoteIp,
256
+ remote_port: remotePort,
257
+ uri,
258
+ },
259
+ resp_headers: collectHeaders(c.res.headers),
260
+ });
261
+ }
262
+ });
263
+
203
264
  // Reuse the cached connInfo instead of re-querying the socket per middleware
204
265
  const cachedGetConnInfo: GetConnInfo = (c) =>
205
266
  (c as Context<{ Variables: HonoVariables }>).get('connInfo') ?? safeGetConnInfo(c);
@@ -320,6 +381,8 @@ function createApp(): Hono<{ Variables: HonoVariables }> {
320
381
  c.set('token', emptyToken);
321
382
  }
322
383
 
384
+ // (access log is a dedicated middleware registered earlier — see top of createApp)
385
+
323
386
  return await next();
324
387
  })
325
388
  })
package/server/mcp.ts CHANGED
@@ -13,6 +13,7 @@ import { StreamableHTTPTransport } from "@hono/mcp";
13
13
  import type Joi from "joi";
14
14
  import { cfg } from "./config";
15
15
  import { useRest } from "../database/rest";
16
+ import { logger } from "../utils/logger";
16
17
  import type { McpTool, McpResource } from "../types/mcp";
17
18
  import type { HonoVariables } from "./env";
18
19
 
@@ -113,24 +114,32 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
113
114
  { capabilities: { tools: {}, resources: {} } }
114
115
  );
115
116
 
116
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
117
- tools: tools.map((t) => ({
117
+ const ms = (start: number) => Math.round((performance.now() - start) * 10) / 10;
118
+
119
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
120
+ const start = performance.now();
121
+ const toolsList = tools.map((t) => ({
118
122
  name: t.name,
119
123
  title: t.name,
120
124
  description: t.description,
121
125
  inputSchema: joiToJsonSchema(t.inputSchema),
122
- })),
123
- }));
126
+ }));
127
+ logger.info('MCP tools/list', { method: 'tools/list', tenant: tenantId, count: toolsList.length, duration: ms(start) });
128
+ return { tools: toolsList };
129
+ });
124
130
 
125
131
  server.setRequestHandler(CallToolRequestSchema, async (req) => {
132
+ const start = performance.now();
126
133
  const tool = tools.find((t) => t.name === req.params.name);
127
134
  if (!tool) {
135
+ logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, error: 'unknown tool', duration: ms(start) });
128
136
  throw new McpError(ErrorCode.MethodNotFound, `Unknown tool: ${req.params.name}`);
129
137
  }
130
138
 
131
139
  const raw = req.params.arguments ?? {};
132
140
  const { error, value } = tool.inputSchema.validate(raw, { allowUnknown: false });
133
141
  if (error) {
142
+ logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: raw, error: 'invalid arguments', duration: ms(start) });
134
143
  return {
135
144
  content: [{ type: 'text' as const, text: `Invalid arguments: ${error.message}` }],
136
145
  isError: true,
@@ -140,10 +149,13 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
140
149
  const rest = new useRest({ tenant_id: tenantId });
141
150
  try {
142
151
  const result = await tool.exec({ c: undefined as any, rest, args: value });
152
+ const isError = !!(result as any)?.isError;
153
+ logger[isError ? 'warn' : 'info']('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: value, isError, duration: ms(start) });
143
154
  if (result && Array.isArray(result.content)) return result;
144
155
  const text = typeof result === 'string' ? result : JSON.stringify(result);
145
156
  return { content: [{ type: 'text' as const, text }] };
146
157
  } catch (err: any) {
158
+ logger.warn('MCP tools/call', { method: 'tools/call', tenant: tenantId, tool: req.params.name, args: value, error: err?.message ?? 'unknown', duration: ms(start) });
147
159
  return {
148
160
  content: [{ type: 'text' as const, text: `Error: ${err?.message ?? 'unknown'}` }],
149
161
  isError: true,
@@ -151,32 +163,40 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
151
163
  }
152
164
  });
153
165
 
154
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({
155
- resources: resources
166
+ server.setRequestHandler(ListResourcesRequestSchema, async () => {
167
+ const start = performance.now();
168
+ const resourcesList = resources
156
169
  .filter((r) => !r.uri.includes('{')) // static only; templates are listed separately
157
170
  .map((r) => ({
158
171
  uri: r.uri,
159
172
  name: r.name,
160
173
  description: r.description,
161
174
  mimeType: r.mimeType,
162
- })),
163
- }));
175
+ }));
176
+ logger.info('MCP resources/list', { method: 'resources/list', tenant: tenantId, count: resourcesList.length, duration: ms(start) });
177
+ return { resources: resourcesList };
178
+ });
164
179
 
165
- server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => ({
166
- resourceTemplates: resources
180
+ server.setRequestHandler(ListResourceTemplatesRequestSchema, async () => {
181
+ const start = performance.now();
182
+ const templates = resources
167
183
  .filter((r) => r.uri.includes('{'))
168
184
  .map((r) => ({
169
185
  uriTemplate: r.uri,
170
186
  name: r.name,
171
187
  description: r.description,
172
188
  mimeType: r.mimeType,
173
- })),
174
- }));
189
+ }));
190
+ logger.info('MCP resources/templates/list', { method: 'resources/templates/list', tenant: tenantId, count: templates.length, duration: ms(start) });
191
+ return { resourceTemplates: templates };
192
+ });
175
193
 
176
194
  server.setRequestHandler(ReadResourceRequestSchema, async (req) => {
195
+ const start = performance.now();
177
196
  const uri = req.params.uri;
178
197
  const matched = matchUri(uri, resources);
179
198
  if (!matched) {
199
+ logger.warn('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: 'unknown uri', duration: ms(start) });
180
200
  throw new McpError(ErrorCode.InvalidParams, `Unknown resource URI: ${uri}`);
181
201
  }
182
202
 
@@ -188,8 +208,10 @@ function buildServer(tenantId: string, tools: McpTool[], resources: McpResource[
188
208
  params: matched.params,
189
209
  uri,
190
210
  });
211
+ logger.info('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, resource: matched.resource.name, params: matched.params, duration: ms(start) });
191
212
  return result;
192
213
  } catch (err: any) {
214
+ logger.warn('MCP resources/read', { method: 'resources/read', tenant: tenantId, uri, error: err?.message ?? 'unknown', duration: ms(start) });
193
215
  throw new McpError(ErrorCode.InternalError, `Failed to read resource: ${err?.message ?? 'unknown'}`);
194
216
  }
195
217
  });
package/server/routes.ts CHANGED
@@ -4,6 +4,7 @@ import path from "path";
4
4
  import { jwt } from "../utils/func";
5
5
  import { useRest } from "../database/rest";
6
6
  import { io } from "./io";
7
+ import { requestCtxStorage } from "../lib/asyncContextStorage";
7
8
  import type { HonoVariables } from "./env";
8
9
 
9
10
  function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
@@ -12,10 +13,17 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
12
13
  let method = route.method.toLocaleLowerCase();
13
14
  let routePath = path.posix.join(route._prefix_, route.path);
14
15
 
16
+ // Resolve the tenant in the request context so the access log,
17
+ // nested useRest() calls and audit see the right tenant_id
18
+ const resolveTenant = () => {
19
+ requestCtxStorage.set('tenant_id', route._tenant_);
20
+ };
21
+
15
22
 
16
23
 
17
24
  if (route.method == "GET") {
18
25
  app.get(routePath, async (c) => { //
26
+ resolveTenant();
19
27
  return route.handler({
20
28
  c,
21
29
  rest: new useRest({
@@ -29,6 +37,7 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
29
37
 
30
38
  if (route.method == 'POST') {
31
39
  app.post(routePath, async (c) => {
40
+ resolveTenant();
32
41
  return route.handler({
33
42
  c,
34
43
  rest: new useRest({
@@ -42,6 +51,7 @@ function initializeRoutes(app: Hono<{ Variables: HonoVariables }>) {
42
51
 
43
52
  if (route.method == "PUT") {
44
53
  app.put(routePath, async (c) => { //
54
+ resolveTenant();
45
55
  return route.handler({
46
56
  c,
47
57
  rest: new useRest({