@anteros/core 0.0.20 → 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/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/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# @
|
|
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 @
|
|
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 "@
|
|
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
|
-
- `
|
|
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)
|
package/index.ts
CHANGED
|
@@ -11,7 +11,7 @@ import * as crypto from "./utils/crypto";
|
|
|
11
11
|
|
|
12
12
|
|
|
13
13
|
|
|
14
|
-
//
|
|
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
|
-
//
|
|
95
|
-
|
|
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
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
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:
|
|
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.
|
|
4
|
+
"version": "0.0.21",
|
|
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]({
|
|
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' });
|