@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.
Files changed (43) hide show
  1. package/README.md +90 -5
  2. package/database/collection.ts +1 -1
  3. package/database/mongodbadapter.ts +3 -0
  4. package/index.ts +1 -1
  5. package/lib/define.ts +60 -3
  6. package/lib/files.ts +15 -27
  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 +5 -2
  11. package/probe-cache.ts +16 -0
  12. package/server/api.ts +4 -1
  13. package/server/boot.ts +75 -13
  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 +185 -14
  18. package/server/mcp.ts +232 -0
  19. package/server/metrics.ts +110 -0
  20. package/server/security.ts +13 -9
  21. package/tests/cache.test.ts +124 -0
  22. package/tests/crypto.test.ts +101 -0
  23. package/tests/fixtures/heavy/collections/items.model.ts +8 -0
  24. package/tests/fixtures/heavy/scripts/heavy-import.run.ts +14 -0
  25. package/tests/fixtures/mcp-tenant/mcp/resources/orders.summary.resource.ts +17 -0
  26. package/tests/fixtures/mcp-tenant/mcp/resources/users.id.resource.ts +18 -0
  27. package/tests/fixtures/mcp-tenant/mcp/tools/badge.tool.ts +16 -0
  28. package/tests/fixtures/mcp-tenant/mcp/tools/echo.tool.ts +19 -0
  29. package/tests/fixtures/metrics-worker.ts +21 -0
  30. package/tests/hono.test.ts +211 -0
  31. package/tests/mcp.test.ts +186 -0
  32. package/tests/metrics-cluster.test.ts +103 -0
  33. package/tests/scripts-heavy.test.ts +49 -0
  34. package/tests/security.test.ts +102 -0
  35. package/tests/utils.test.ts +113 -0
  36. package/types/collection.d.ts +20 -5
  37. package/types/config.d.ts +20 -3
  38. package/types/hook.d.ts +16 -13
  39. package/types/mcp.d.ts +81 -0
  40. package/types/scripts.d.ts +14 -1
  41. package/utils/cache.ts +20 -1
  42. package/utils/crypto.ts +25 -8
  43. package/utils/func.ts +12 -4
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # @dnax/core
1
+ # @anteros/core
2
2
 
3
3
  Server core for **multi-tenant** apps: generic **MongoDB** REST API, **Joi** validation, hooks, JWT auth, custom routes, and boot scripts. Built for **Bun** with **Hono** and **Socket.io**.
4
4
 
@@ -11,7 +11,7 @@ Server core for **multi-tenant** apps: generic **MongoDB** REST API, **Joi** val
11
11
  ## Installation
12
12
 
13
13
  ```bash
14
- bun add @dnax/core@0.0.0-rc.13
14
+ bun add @anteros/core@latest
15
15
  ```
16
16
 
17
17
  ## Quick start
@@ -19,10 +19,9 @@ bun add @dnax/core@0.0.0-rc.13
19
19
  Typical entrypoint in your app (working directory = project root that contains tenant folders):
20
20
 
21
21
  ```ts
22
- import { app, define } from "@dnax/core";
22
+ import { app, define } from "@anteros/core";
23
23
 
24
24
  await app.boot({
25
- clusterMode: false,
26
25
  server: {
27
26
  port: 5000,
28
27
  jwt: {
@@ -57,6 +56,7 @@ On boot, the server syncs tenants, loads collections (`*.model.ts`), registers r
57
56
  | `app` | `{ boot }` — starts the application |
58
57
  | `useRest` | REST client / per-tenant Mongo access (programmatic use or in handlers) |
59
58
  | `v` | **Joi** (re-export), for schemas in models |
59
+ | `cache` | Native caching: `useMemoryCache`, `useFilesystemCache`, `useRedisCache` (Bun.Redis) |
60
60
  | `utils` | Internal utilities exposed by the package |
61
61
 
62
62
  ## Folder layout (per tenant)
@@ -116,11 +116,96 @@ Per collection, `api.access` allows or denies each action (`boolean` or async fu
116
116
 
117
117
  When `api.auth.enabled` is set, define `onLogin` / `onLogout` on the model. The JWT secret is read from `server.jwt.secret` or the **`JWT_SECRET`** environment variable.
118
118
 
119
+ ## Health & metrics
120
+
121
+ The server exposes a built-in **`GET /health`** endpoint (zero dependency) reporting live process metrics:
122
+
123
+ ```json
124
+ {
125
+ "pid": 10047,
126
+ "startedAt": 1720000000000,
127
+ "uptime": 3600,
128
+ "env": "production",
129
+ "connections": 0,
130
+ "pendingRequests": 3,
131
+ "pendingWebSockets": 7,
132
+ "requests": {
133
+ "total": 1523,
134
+ "active": 3,
135
+ "errors": 2,
136
+ "byMethod": { "GET": 1200, "POST": 323 },
137
+ "byStatus": { "200": 1500, "201": 21, "500": 2 }
138
+ },
139
+ "memory": { "rss": 13778944, "heapUsed": 112384, "heapTotal": 521216, "external": 10624 },
140
+ "cpu": { "percent": 12.5, "cores": 8, "user": 120000, "system": 80000 }
141
+ }
142
+ ```
143
+
144
+ | Field | Meaning |
145
+ |-------|---------|
146
+ | `pid` / `uptime` | Process id / seconds since boot |
147
+ | `requests.total` / `requests.errors` | Total requests served / responses ≥ 500 |
148
+ | `requests.byMethod` / `requests.byStatus` | Counters split by HTTP method / status |
149
+ | `memory` | V8 heap + RSS usage (bytes) |
150
+ | `pendingRequests` | Requests currently being processed |
151
+ | `pendingWebSockets` | Active WebSocket connections |
152
+ | `cpu.percent` | % of a single core over the last sample window |
153
+
154
+ With `reusePort`, each process exposes its **own** metrics — useful to see per-worker load.
155
+
156
+ ## Cluster (reusePort + multiple processes)
157
+
158
+ Set `server.reusePort: true` to enable Bun's `SO_REUSEPORT`. The framework then acts as a **master**:
159
+
160
+ - Spawns N workers (`server.workers`, default = CPU count)
161
+ - Each worker serves the main port (the OS round-robins connections)
162
+ - Workers report metrics to the master **via IPC** every 5s
163
+ - The master **restarts any worker that crashes** (auto-respawn)
164
+ - The master exposes an **aggregated `/health`** on `server.metricsPort` (default: `port + 1`)
165
+
166
+ ```typescript
167
+ await app.boot({
168
+ server: {
169
+ port: 5000,
170
+ reusePort: true, // cluster mode
171
+ workers: 4, // optional, default = CPU count
172
+ metricsPort: 5001, // optional, default = port + 1
173
+ },
174
+ // ...
175
+ });
176
+ ```
177
+
178
+ ```bash
179
+ curl http://localhost:5001/health # aggregated across all workers
180
+ ```
181
+
182
+ ```json
183
+ {
184
+ "status": "ok",
185
+ "master": { "pid": 1000 },
186
+ "workers": 4,
187
+ "uptime": { "min": 12, "max": 15 },
188
+ "requests": {
189
+ "total": 1523,
190
+ "active": 11,
191
+ "errors": 2,
192
+ "perSecond": 101,
193
+ "byMethod": { "GET": 1200, "POST": 323 },
194
+ "byStatus": { "200": 1500, "201": 21, "500": 2 }
195
+ },
196
+ "memory": { "rss": 54067200, "heapUsed": 589294 },
197
+ "cpu": { "percent": 25.5, "cores": 8 },
198
+ "pids": [1001, 1002, 1003, 1004]
199
+ }
200
+ ```
201
+
202
+ > ⚠️ Each process has its own memory — use **Redis** for shared cache / Socket.io state.
203
+
119
204
  ## Server configuration
120
205
 
121
206
  Common options when calling `app.boot`:
122
207
 
123
- - `clusterMode` — Bun `reusePort` for multiple workers
208
+ - `server.reusePort` — Bun `SO_REUSEPORT` (share the port across multiple processes)
124
209
  - `server.port`, `server.name`
125
210
  - `server.cors` — origins, credentials, headers, and methods
126
211
  - `server.ipRestriction` — allow/deny lists (Hono)
@@ -142,7 +142,7 @@ async function initializeOnDatabase(collections: Collection[]) {
142
142
  }
143
143
 
144
144
  } catch (err: any) {
145
- console.error(err?.message)
145
+ console.error(`col: ${collection?.slug||''}`,err?.message)
146
146
 
147
147
  }
148
148
  }
@@ -148,6 +148,9 @@ class MongoRest {
148
148
  }
149
149
 
150
150
  }
151
+
152
+ // No schema or nothing to sanitize → pass the raw data through
153
+ return options.data;
151
154
  }
152
155
 
153
156
  async #logActivity(data: {
package/index.ts CHANGED
@@ -11,7 +11,7 @@ import * as crypto from "./utils/crypto";
11
11
 
12
12
 
13
13
 
14
- // Imort bentocache use as cache
14
+ // BentoCache-based caching (memory L1 + filesystem/Redis L2)
15
15
  import { useMemoryCache, useFilesystemCache, useRedisCache } from "./utils/cache";
16
16
  const cache = {
17
17
  useMemoryCache,
package/lib/define.ts CHANGED
@@ -1,14 +1,15 @@
1
1
  import type { ServerConfig } from "../types/config";
2
- import type { Collection } from "../types/collection";
2
+ import type { Collection, CollectionAction } from "../types/collection";
3
+ import type { CollectionHook } from "../types/hook";
3
4
  import type { FileCollection } from "../types/file";
4
5
  import type { Script } from "../types/scripts";
5
6
  import type { Route } from "../types/route";
6
7
  import type { Service } from "../types/service";
7
8
  import type { WebSocketHandler } from "../types/websocket";
8
9
  import type { TenantMiddlewareConfig, GlobalMiddlewareConfig } from "../types/middleware";
10
+ import type { McpTool, McpResource } from "../types/mcp";
9
11
  import { file } from "bun";
10
12
  function Server(config: ServerConfig) {
11
- config.clusterMode = config?.clusterMode ?? true;
12
13
  config.server = {
13
14
  ...config?.server,
14
15
  }
@@ -32,6 +33,58 @@ function Collection(collection: Collection): Collection {
32
33
  }
33
34
  }
34
35
 
36
+ /**
37
+ * Defines a custom action on a collection — usable in `define.Collection({ actions: { ... } })`.
38
+ *
39
+ * @example
40
+ * ```ts
41
+ * const processOrder = define.Action(async ({ rest, data, error }) => {
42
+ * const order = await rest.findOne("orders", data.orderId);
43
+ * if (!order) throw error("Order not found", { status: 404 });
44
+ * return { ok: true };
45
+ * });
46
+ *
47
+ * define.Collection({ slug: "orders", actions: { processOrder } })
48
+ * ```
49
+ */
50
+ function Action(action: CollectionAction): CollectionAction & { _isAction_: true } {
51
+ return Object.assign(action, { _isAction_: true }) as CollectionAction & { _isAction_: true };
52
+ }
53
+
54
+ /**
55
+ * Defines a collection hook — usable as `beforeOperation` / `afterOperation`.
56
+ *
57
+ * @example
58
+ * ```ts
59
+ * const softDelete = define.Hook(async ({ rest, action, meta }) => {
60
+ * if (action === 'deleteOne' && meta.id) {
61
+ * await rest.updateOne("items", meta.id, { $set: { deletedAt: new Date().toISOString() } });
62
+ * }
63
+ * });
64
+ *
65
+ * define.Collection({ slug: "items", hooks: { beforeOperation: softDelete } })
66
+ * ```
67
+ */
68
+ function Hook(hook: CollectionHook): CollectionHook & { _isHook_: true } {
69
+ return Object.assign(hook, { _isHook_: true }) as CollectionHook & { _isHook_: true };
70
+ }
71
+
72
+ function McpTool(tool: McpTool): McpTool {
73
+ return {
74
+ ...tool,
75
+ enabled: tool.enabled ?? true,
76
+ _isMcpTool_: true,
77
+ }
78
+ }
79
+
80
+ function McpResource(resource: McpResource): McpResource {
81
+ return {
82
+ ...resource,
83
+ enabled: resource.enabled ?? true,
84
+ _isMcpResource_: true,
85
+ }
86
+ }
87
+
35
88
  function FileCollection(collection: FileCollection): FileCollection {
36
89
  return {
37
90
  ...collection,
@@ -103,6 +156,8 @@ export const define = {
103
156
  Server: Server,
104
157
  App: Server,
105
158
  Collection,
159
+ Action,
160
+ Hook,
106
161
  FileCollection,
107
162
  Script,
108
163
  Route,
@@ -110,5 +165,7 @@ export const define = {
110
165
  Workflow,
111
166
  TenantMiddleware,
112
167
  Middleware,
113
- WebSocket
168
+ WebSocket,
169
+ McpTool,
170
+ McpResource
114
171
  }
package/lib/files.ts CHANGED
@@ -2,6 +2,7 @@ import { getFileCollection } from "../database/file";
2
2
  import { useRest } from "../database/rest";
3
3
  import { cfg } from "../server/config";
4
4
  import { AppError } from "./error";
5
+ import { S3Client } from "bun";
5
6
  import type { FileCollection } from "../types/file";
6
7
  import path from "path";
7
8
  import fs from "fs/promises";
@@ -84,6 +85,14 @@ export type S3Config = {
84
85
  };
85
86
 
86
87
  export function createS3Storage(config: S3Config): FileStorage {
88
+ const client = new S3Client({
89
+ region: config.region,
90
+ bucket: config.bucket,
91
+ accessKeyId: config.accessKeyId,
92
+ secretAccessKey: config.secretAccessKey,
93
+ ...(config.endpoint ? { endpoint: config.endpoint } : {}),
94
+ });
95
+
87
96
  return {
88
97
  async save(tenant_id, collection, file, meta) {
89
98
  const { subpath } = meta;
@@ -91,42 +100,21 @@ export function createS3Storage(config: S3Config): FileStorage {
91
100
  const filename = `${meta.id}${ext}`;
92
101
  const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
93
102
  const buffer = await file.arrayBuffer();
94
- // S3 upload via fetch (AWS S3 REST API)
95
- const url = config.endpoint
96
- ? `${config.endpoint}/${config.bucket}/${key}`
97
- : `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
98
- const response = await fetch(url, {
99
- method: 'PUT',
100
- headers: {
101
- 'Content-Type': meta.mimetype,
102
- 'Content-Length': String(buffer.byteLength),
103
- },
104
- body: buffer,
105
- });
106
- if (!response.ok) {
107
- throw new AppError(`S3 upload failed: ${response.statusText}`, {
108
- code: 'S3_UPLOAD_ERROR', status: 500,
109
- });
110
- }
103
+ // Native Bun S3 client (handles multipart, retries, signing)
104
+ await client.write(key, buffer, { type: meta.mimetype });
111
105
  return { path: key, size: buffer.byteLength };
112
106
  },
113
107
 
114
108
  async getStream(tenant_id, collection, fileId, filename, subpath) {
115
109
  const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
116
- const url = config.endpoint
117
- ? `${config.endpoint}/${config.bucket}/${key}`
118
- : `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
119
- const response = await fetch(url);
120
- if (!response.ok) return null;
121
- return response.body;
110
+ const s3file = client.file(key);
111
+ if (!(await s3file.exists())) return null;
112
+ return s3file.stream();
122
113
  },
123
114
 
124
115
  async delete(tenant_id, collection, fileId, filename, subpath) {
125
116
  const key = subpath ? `${subpath}/${filename}` : `${tenant_id}/${collection}/${filename}`;
126
- const url = config.endpoint
127
- ? `${config.endpoint}/${config.bucket}/${key}`
128
- : `https://${config.bucket}.s3.${config.region}.amazonaws.com/${key}`;
129
- await fetch(url, { method: 'DELETE' });
117
+ await client.delete(key).catch(() => {});
130
118
  },
131
119
  };
132
120
  }
package/lib/mcp.ts ADDED
@@ -0,0 +1,59 @@
1
+ import { Glob } from "bun"
2
+ import { cfg } from "../server/config"
3
+ import path from "path"
4
+ import fs from "fs/promises"
5
+ import type { McpTool, McpResource } from "../types/mcp"
6
+
7
+ /**
8
+ * Load MCP tools & resources from each tenant's `mcp/` folder:
9
+ * {tenant.dir}/mcp/tools/**\/*.tool.ts → tools
10
+ * {tenant.dir}/mcp/resources/**\/*.resource.ts → resources
11
+ */
12
+ async function syncMcpTools() {
13
+ try {
14
+ const tools: McpTool[] = []
15
+ const resources: McpResource[] = []
16
+ for (let tenant of cfg.tenants ?? []) {
17
+ const TENANT_PATH = path.join(process.cwd(), tenant.dir)
18
+ const MCP_PATH = path.join(TENANT_PATH, 'mcp')
19
+ let exist = await fs.exists(MCP_PATH)
20
+ if (!exist) continue;
21
+ const isDirectory = await (await fs.stat(MCP_PATH)).isDirectory()
22
+ if (!isDirectory) continue;
23
+
24
+ // tools: mcp/tools/**/*.tool.ts
25
+ const TOOLS_PATH = path.join(MCP_PATH, 'tools')
26
+ if (await fs.exists(TOOLS_PATH)) {
27
+ const toolsGlob = new Glob(path.join(TOOLS_PATH, '**/*.tool.ts'))
28
+ for await (let file of toolsGlob.scan('.')) {
29
+ let module = await import(file)
30
+ if (module?.default?._isMcpTool_ && module?.default?.enabled !== false) {
31
+ tools.push({ ...module?.default, _tenant_: tenant.id })
32
+ }
33
+ }
34
+ }
35
+
36
+ // resources: mcp/resources/**/*.resource.ts
37
+ const RESOURCES_PATH = path.join(MCP_PATH, 'resources')
38
+ if (await fs.exists(RESOURCES_PATH)) {
39
+ const resourcesGlob = new Glob(path.join(RESOURCES_PATH, '**/*.resource.ts'))
40
+ for await (let file of resourcesGlob.scan('.')) {
41
+ let module = await import(file)
42
+ if (module?.default?._isMcpResource_ && module?.default?.enabled !== false) {
43
+ resources.push({ ...module?.default, _tenant_: tenant.id })
44
+ }
45
+ }
46
+ }
47
+ }
48
+
49
+ cfg.mcpTools = tools
50
+ cfg.mcpResources = resources
51
+
52
+ } catch (err: any) {
53
+ console.error(err?.message)
54
+ }
55
+ }
56
+
57
+ export {
58
+ syncMcpTools
59
+ }
@@ -0,0 +1,76 @@
1
+ import { cfg } from "../server/config";
2
+ import { useRest } from "../database/rest";
3
+ import { syncTenants } from "../database/tenant";
4
+ import { syncCollections } from "../database/collection";
5
+ import { syncFileCollections } from "../database/file";
6
+ import { asyncContextStorage, requestCtxStorage } from "./asyncContextStorage";
7
+
8
+ // ─── Heavy-script worker entrypoint ─────────────────────────────────────────
9
+ // Spawned by runScripts() for scripts marked `heavy: true`.
10
+ //
11
+ // bun run lib/scriptWorker.ts <absolute-script-path>
12
+ //
13
+ // The tenant config (id, dir, database.uri…) is received as JSON on **stdin**
14
+ // (never argv, to avoid leaking credentials in `ps`). The worker rebuilds the
15
+ // minimal app context — tenant DB connection + collection schemas — then runs
16
+ // the script and reports progress via IPC (process.send).
17
+
18
+ async function main() {
19
+ const scriptPath = process.argv[2];
20
+ if (!scriptPath) {
21
+ console.error('[script-worker] usage: scriptWorker <scriptPath>');
22
+ process.exit(1);
23
+ }
24
+
25
+ // Read the tenant config from stdin (written by the parent)
26
+ let tenant: any;
27
+ try {
28
+ const input = (await new Response(process.stdin).json()) as { tenant?: any };
29
+ tenant = input?.tenant;
30
+ } catch (err: any) {
31
+ console.error('[script-worker] failed to read tenant config from stdin:', err?.message);
32
+ process.exit(1);
33
+ }
34
+ if (!tenant?.id || !tenant?.database?.uri) {
35
+ console.error('[script-worker] missing tenant config (id / database.uri)');
36
+ process.exit(1);
37
+ }
38
+
39
+ try {
40
+ // Rebuild the app context, same as boot: connect DB, load collection schemas
41
+ cfg.tenants = [tenant];
42
+ await syncTenants();
43
+ await syncCollections();
44
+ await syncFileCollections();
45
+ } catch (err: any) {
46
+ console.error('[script-worker] failed to bootstrap context:', err?.message);
47
+ process.exit(1);
48
+ }
49
+
50
+ const module = await import(scriptPath);
51
+ const script = module?.default;
52
+ if (!script?._isScript_ || !script.enabled) {
53
+ console.error(`[script-worker] '${scriptPath}' is not an enabled script`);
54
+ process.exit(1);
55
+ }
56
+
57
+ const rest = new useRest({ tenant_id: tenant.id });
58
+ const progress = (percent: number, info?: any) =>
59
+ process.send?.({ type: 'progress', percent, info });
60
+
61
+ try {
62
+ await asyncContextStorage.run(new Map(), async () => {
63
+ requestCtxStorage.set('trace', { id: crypto.randomUUID() });
64
+ requestCtxStorage.set('internal', true);
65
+ await script.exec({ rest, progress, tenant });
66
+ });
67
+ process.send?.({ type: 'done' });
68
+ process.exit(0);
69
+ } catch (err: any) {
70
+ process.send?.({ type: 'error', message: err?.message });
71
+ console.error('[script-worker] script failed:', err);
72
+ process.exit(1);
73
+ }
74
+ }
75
+
76
+ main();
package/lib/scripts.ts CHANGED
@@ -3,12 +3,70 @@ import { cfg } from "../server/config"
3
3
  import path from "path"
4
4
  import fs from "fs/promises"
5
5
  import type { Script } from "../types/scripts"
6
+ import type { Tenant } from "../types/tenant"
6
7
  import { useRest } from "../database/rest"
7
8
  import { asyncContextStorage, requestCtxStorage } from "./asyncContextStorage"
8
9
 
9
10
 
11
+ // Path of the heavy-script worker entrypoint (spawned as its own Bun process)
12
+ const WORKER_PATH = path.join(import.meta.dir, 'scriptWorker.ts');
13
+
14
+ /**
15
+ * Run a `heavy` script in a separate Bun process so it never blocks the HTTP
16
+ * server. The worker reconnects the tenant DB and rebuilds collection schemas.
17
+ * Progress is reported back via IPC when the script calls `progress(...)`.
18
+ */
19
+ async function runHeavyScript(file: string, tenant: Tenant, timeout?: number): Promise<void> {
20
+ const scriptPath = path.resolve(file);
21
+ const proc = Bun.spawn(['bun', 'run', WORKER_PATH, scriptPath], {
22
+ env: { ...process.env },
23
+ stdin: 'pipe',
24
+ stdout: 'inherit',
25
+ stderr: 'inherit',
26
+ ipc: (message: any) => {
27
+ if (message?.type === 'progress') {
28
+ console.log(`[script:${path.basename(file)}] ${message.percent}%`, message.info ?? '');
29
+ } else if (message?.type === 'error') {
30
+ console.error(`[script:${path.basename(file)}]`, message?.message);
31
+ }
32
+ },
33
+ });
34
+
35
+ // Send the serializable tenant config (uri/options only — never client/db
36
+ // Mongo instances, which are not JSON-serializable)
37
+ const payload = JSON.stringify({
38
+ tenant: {
39
+ id: tenant.id,
40
+ name: tenant.name,
41
+ description: tenant.description,
42
+ dir: tenant.dir,
43
+ routes: tenant.routes,
44
+ database: {
45
+ uri: tenant.database?.uri,
46
+ options: tenant.database?.options,
47
+ },
48
+ },
49
+ });
50
+ proc.stdin.write(payload);
51
+ proc.stdin.end();
52
+
53
+ // Optional hard timeout — kills the worker if it exceeds it
54
+ const timer = timeout ? setTimeout(() => {
55
+ console.error(`[script:${path.basename(file)}] timed out after ${timeout}ms — killing`);
56
+ proc.kill();
57
+ }, timeout) : null;
58
+
59
+ const code = await proc.exited;
60
+ if (timer) clearTimeout(timer);
61
+ if (code !== 0) {
62
+ console.error(`[script:${path.basename(file)}] exited with code ${code}`);
63
+ }
64
+ }
65
+
66
+
10
67
  async function runScripts() {
11
- let scripts: (Script & { rest: InstanceType<typeof useRest> })[] = []
68
+ type ScriptEntry = { script: Script; file: string; tenant: Tenant };
69
+ const entries: ScriptEntry[] = [];
12
70
  for (let tenant of cfg.tenants ?? []) {
13
71
  const TENANT_PATH = path.join(process.cwd(), tenant.dir)
14
72
  const SCRIPTS_PATH = path.join(TENANT_PATH, 'scripts')
@@ -20,28 +78,35 @@ async function runScripts() {
20
78
  for await (let file of glob.scan('.')) {
21
79
  let scriptModule = await import(file)
22
80
  if (scriptModule?.default?._isScript_ && scriptModule?.default?.enabled) {
23
- scripts.push({
24
- ...scriptModule?.default,
25
- rest: new useRest({
26
- tenant_id: tenant.id,
27
- })
28
- })
81
+ entries.push({ script: scriptModule.default, file, tenant })
29
82
  }
30
83
  }
31
84
  }
32
85
  }
33
86
 
34
- for (let script of scripts) {
35
- asyncContextStorage.run(new Map(), async () => {
87
+ const heavyTasks: Promise<void>[] = [];
88
+ for (const { script, file, tenant } of entries) {
89
+ if (script.heavy) {
90
+ // Heavy scripts run in a subprocess — never block the server
91
+ heavyTasks.push(runHeavyScript(file, tenant, script.timeout));
92
+ continue;
93
+ }
94
+
95
+ await asyncContextStorage.run(new Map(), async () => {
36
96
  requestCtxStorage.set('trace', { id: crypto.randomUUID() })
37
97
  requestCtxStorage.set('internal', true)
38
-
39
98
  await script.exec({
40
- rest: script.rest
99
+ rest: new useRest({ tenant_id: tenant.id }),
100
+ tenant,
41
101
  })
42
102
  })
43
103
  }
104
+
105
+ // Wait for all heavy subprocesses (they keep running after this resolves)
106
+ if (heavyTasks.length) {
107
+ await Promise.allSettled(heavyTasks);
108
+ }
44
109
  }
45
110
  export {
46
111
  runScripts
47
- }
112
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@anteros/core",
3
3
  "module": "index.ts",
4
- "version": "0.0.20",
4
+ "version": "0.0.22",
5
5
  "type": "module",
6
6
  "description": "Anteros Core",
7
7
  "private": false,
@@ -17,6 +17,8 @@
17
17
  },
18
18
  "dependencies": {
19
19
  "@colors/colors": "1.6.0",
20
+ "@hono/mcp": "^0.3.1",
21
+ "@modelcontextprotocol/sdk": "^1.30.0",
20
22
  "@socket.io/bun-engine": "0.1.1",
21
23
  "bentocache": "1.6.1",
22
24
  "boxen": "^8.0.1",
@@ -27,6 +29,7 @@
27
29
  "jose": "6.2.8",
28
30
  "mongodb": "7.5.0",
29
31
  "socket.io": "4.8.3",
30
- "ufo": "1.6.4"
32
+ "ufo": "1.6.4",
33
+ "zod": "^4.4.3"
31
34
  }
32
35
  }
package/probe-cache.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { Cache } from "./utils/cache";
2
+
3
+ async function probe() {
4
+ const c = new Cache("memory");
5
+ const v = await c.get({ key: "k" });
6
+ const p = await c.pull({ key: "p" });
7
+ const g = await c.getOrSet({ key: "g", factory: () => 42 });
8
+ const s = await c.set({ key: "a", value: 1 });
9
+ const h = await c.has({ key: "a" });
10
+ const i: unknown = v;
11
+ const j: unknown = p;
12
+ const k: unknown = g;
13
+ console.log(typeof v, typeof p, typeof g, s, h);
14
+ void i; void j; void k;
15
+ }
16
+ void probe;
package/server/api.ts CHANGED
@@ -351,7 +351,10 @@ function initializeApi(app: Hono<{ Variables: HonoVariables }>) {
351
351
 
352
352
  // ── Execute action ──
353
353
  if (col.actions?.[action]) {
354
- 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
+ });
355
358
  } else {
356
359
  const handler = crudHandlers[action];
357
360
  if (!handler) throw new AppError('Action `' + action + '` not found', { status: 400, code: 'ACTION_NOT_FOUND' });