@zhin.js/host-http 1.0.2 → 1.0.4
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 +3 -2
- package/lib/console-rest-pages.js +19 -7
- package/lib/console-rpc-extended.d.ts +2 -2
- package/lib/console-rpc-extended.js +19 -3
- package/lib/http-host.js +9 -0
- package/lib/json-body.js +10 -2
- package/lib/project-files.js +10 -0
- package/package.json +5 -5
- package/src/console-rest-pages.ts +23 -7
- package/src/console-rpc-extended.ts +39 -7
- package/src/http-host.ts +9 -0
- package/src/json-body.ts +8 -2
- package/src/project-files.ts +10 -0
package/README.md
CHANGED
|
@@ -45,5 +45,6 @@ http:
|
|
|
45
45
|
base: /api # auth prefix
|
|
46
46
|
```
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
The legacy Koa `@zhin.js/host-router` stack and the `@zhin.js/host-api` management-plane plugin have
|
|
49
|
+
been removed; the management-plane REST/RPC/SSE is now assembled by `@zhin.js/cli` (Console Host) on
|
|
50
|
+
top of this package — no Host plugins need to be installed or enabled.
|
|
@@ -17,8 +17,7 @@ export function registerConsoleRestPages(host, ctx, options = {}) {
|
|
|
17
17
|
const base = normalizeBase(options.apiBase ?? '/api');
|
|
18
18
|
const fetchFn = options.fetchFn ?? fetch;
|
|
19
19
|
const pluginRegistryUrl = options.pluginRegistryUrl ?? 'https://zhin.js.org/plugins.json';
|
|
20
|
-
const npmRegistryUrl = (options.npmRegistryUrl ?? 'https://registry.npmmirror.com')
|
|
21
|
-
.replace(/\/+$/u, '');
|
|
20
|
+
const npmRegistryUrl = trimTrailingSlashes(options.npmRegistryUrl ?? 'https://registry.npmmirror.com');
|
|
22
21
|
const disposers = [];
|
|
23
22
|
const route = (method, path, handler, meta) => {
|
|
24
23
|
const dispose = host.route(method, path, handler, meta);
|
|
@@ -99,12 +98,19 @@ function registerLogsRoutes(route, base, ctx) {
|
|
|
99
98
|
});
|
|
100
99
|
return;
|
|
101
100
|
}
|
|
102
|
-
|
|
101
|
+
// 计数走 DB 侧聚合(count);模型未实现 count 时降级为 select('id') 只取主键列
|
|
102
|
+
const countRows = async (where) => {
|
|
103
|
+
if (typeof LogModel.count === 'function')
|
|
104
|
+
return LogModel.count(where);
|
|
105
|
+
const selection = LogModel.select('id');
|
|
106
|
+
const rows = await (where ? selection.where(where) : selection);
|
|
107
|
+
return rows.length;
|
|
108
|
+
};
|
|
109
|
+
const total = await countRows();
|
|
103
110
|
const levels = ['info', 'warn', 'error'];
|
|
104
111
|
const levelCounts = {};
|
|
105
112
|
for (const level of levels) {
|
|
106
|
-
|
|
107
|
-
levelCounts[level] = count.length;
|
|
113
|
+
levelCounts[level] = await countRows({ level });
|
|
108
114
|
}
|
|
109
115
|
const oldestLog = await LogModel.select('timestamp')
|
|
110
116
|
.orderBy('timestamp', 'ASC')
|
|
@@ -116,7 +122,7 @@ function registerLogsRoutes(route, base, ctx) {
|
|
|
116
122
|
: null;
|
|
117
123
|
writeJson(response, 200, {
|
|
118
124
|
success: true,
|
|
119
|
-
data: { total
|
|
125
|
+
data: { total, byLevel: levelCounts, oldestTimestamp },
|
|
120
126
|
});
|
|
121
127
|
}, { summary: 'System log stats', tags: ['console', 'logs'] });
|
|
122
128
|
route('POST', `${base}/logs/cleanup`, async (request, response, _url, authScope) => {
|
|
@@ -665,10 +671,16 @@ function decodePathParam(raw) {
|
|
|
665
671
|
return raw;
|
|
666
672
|
}
|
|
667
673
|
}
|
|
674
|
+
function trimTrailingSlashes(s) {
|
|
675
|
+
let i = s.length;
|
|
676
|
+
while (i > 0 && s[i - 1] === '/')
|
|
677
|
+
i--;
|
|
678
|
+
return s.slice(0, i);
|
|
679
|
+
}
|
|
668
680
|
function normalizeBase(value) {
|
|
669
681
|
if (!value.startsWith('/'))
|
|
670
682
|
return `/${value}`;
|
|
671
|
-
return value
|
|
683
|
+
return trimTrailingSlashes(value) || '/api';
|
|
672
684
|
}
|
|
673
685
|
function writeJson(response, status, body) {
|
|
674
686
|
const payload = JSON.stringify(body);
|
|
@@ -47,12 +47,12 @@ export type ExtendedRpcResult = {
|
|
|
47
47
|
};
|
|
48
48
|
interface EndpointManagementPort {
|
|
49
49
|
listFriends?(): Promise<readonly {
|
|
50
|
-
user_id: number;
|
|
50
|
+
user_id: number | string;
|
|
51
51
|
nickname: string;
|
|
52
52
|
remark: string;
|
|
53
53
|
}[]>;
|
|
54
54
|
listGroups?(): Promise<readonly {
|
|
55
|
-
group_id: number;
|
|
55
|
+
group_id: number | string;
|
|
56
56
|
name: string;
|
|
57
57
|
}[]>;
|
|
58
58
|
listChannels?(): Promise<readonly {
|
|
@@ -203,12 +203,19 @@ function getInboxModel(ctx, table) {
|
|
|
203
203
|
return undefined;
|
|
204
204
|
return model;
|
|
205
205
|
}
|
|
206
|
-
async function readInboxRows(ctx, table, where) {
|
|
206
|
+
async function readInboxRows(ctx, table, where, options) {
|
|
207
207
|
const model = getInboxModel(ctx, table);
|
|
208
208
|
if (!model)
|
|
209
209
|
return { rows: [], enabled: false };
|
|
210
210
|
try {
|
|
211
|
-
|
|
211
|
+
let selection = model.select().where(where);
|
|
212
|
+
if (options?.orderBy && typeof selection.orderBy === 'function') {
|
|
213
|
+
selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
|
|
214
|
+
}
|
|
215
|
+
if (options?.limit != null && typeof selection.limit === 'function') {
|
|
216
|
+
selection = selection.limit(options.limit);
|
|
217
|
+
}
|
|
218
|
+
const rows = await selection;
|
|
212
219
|
return { rows: Array.isArray(rows) ? rows : [], enabled: true };
|
|
213
220
|
}
|
|
214
221
|
catch {
|
|
@@ -226,6 +233,10 @@ async function listInbox(d, ctx, table, key, mapRow) {
|
|
|
226
233
|
const { rows, enabled } = await readInboxRows(ctx, table, {
|
|
227
234
|
adapter,
|
|
228
235
|
endpoint_id: endpointId,
|
|
236
|
+
}, {
|
|
237
|
+
// sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
|
|
238
|
+
orderBy: { field: 'created_at', direction: 'DESC' },
|
|
239
|
+
limit: offset + limit,
|
|
229
240
|
});
|
|
230
241
|
const sorted = rows
|
|
231
242
|
.slice()
|
|
@@ -243,6 +254,8 @@ async function listPendingRequests(d, ctx) {
|
|
|
243
254
|
const { rows, enabled } = await readInboxRows(ctx, TABLE_REQUEST, {
|
|
244
255
|
adapter,
|
|
245
256
|
endpoint_id: endpointId,
|
|
257
|
+
}, {
|
|
258
|
+
orderBy: { field: 'created_at', direction: 'ASC' },
|
|
246
259
|
});
|
|
247
260
|
const requests = rows
|
|
248
261
|
.filter((row) => Number(row.resolved ?? 0) === 0)
|
|
@@ -272,7 +285,10 @@ async function listInboxMessages(d, ctx) {
|
|
|
272
285
|
where.channel_parent_type = parent.type;
|
|
273
286
|
where.channel_parent_id = parent.id;
|
|
274
287
|
}
|
|
275
|
-
const { rows, enabled } = await readInboxRows(ctx, TABLE_MESSAGE, where
|
|
288
|
+
const { rows, enabled } = await readInboxRows(ctx, TABLE_MESSAGE, where, {
|
|
289
|
+
// before_ts/before_id 过滤保留在内存(涉及双字段比较),排序下推 DB 侧
|
|
290
|
+
orderBy: { field: 'created_at', direction: 'DESC' },
|
|
291
|
+
});
|
|
276
292
|
const messages = rows
|
|
277
293
|
.filter((row) => (beforeTs == null || Number(row.created_at ?? 0) < beforeTs)
|
|
278
294
|
&& (beforeId == null || Number(row.id ?? 0) < beforeId))
|
package/lib/http-host.js
CHANGED
|
@@ -5,6 +5,7 @@ import { createToken } from '@zhin.js/plugin-runtime';
|
|
|
5
5
|
import { TokenRegistry, extractBearerToken, isDemoWebSocketPath, } from './token-registry.js';
|
|
6
6
|
import { buildOpenApiDocument, } from './openapi.js';
|
|
7
7
|
import { isDemoHttpAllowed } from './console-rpc.js';
|
|
8
|
+
import { HttpBodyError } from './json-body.js';
|
|
8
9
|
const logger = getLogger('HttpHost');
|
|
9
10
|
export const REMOTE_CONSOLE_ORIGIN = 'https://console.zhin.dev';
|
|
10
11
|
const DEFAULT_CORS_ORIGINS = Object.freeze([REMOTE_CONSOLE_ORIGIN]);
|
|
@@ -180,6 +181,14 @@ export function createHttpHost(options = {}) {
|
|
|
180
181
|
await route.handler(request, response, url, auth.scope);
|
|
181
182
|
}
|
|
182
183
|
catch (err) {
|
|
184
|
+
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
185
|
+
// 避免个别端点未捕获时退化成 500 空响应。
|
|
186
|
+
if (err instanceof HttpBodyError) {
|
|
187
|
+
if (!response.headersSent) {
|
|
188
|
+
writeJson(response, err.statusCode, { success: false, error: err.message });
|
|
189
|
+
}
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
183
192
|
logger.warn(formatCompact({
|
|
184
193
|
op: 'http_handler_failed',
|
|
185
194
|
path: pathname,
|
package/lib/json-body.js
CHANGED
|
@@ -14,15 +14,23 @@ export async function readJsonBody(request, options = {}) {
|
|
|
14
14
|
const limit = options.limit ?? 1_048_576;
|
|
15
15
|
const chunks = [];
|
|
16
16
|
let size = 0;
|
|
17
|
+
let exceeded = false;
|
|
17
18
|
for await (const chunk of request) {
|
|
19
|
+
// 超限后继续 drain 到 end:提前 break 会 destroy 底层 socket,
|
|
20
|
+
// 客户端将永远收不到后面的 413 响应。
|
|
21
|
+
if (exceeded)
|
|
22
|
+
continue;
|
|
18
23
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
19
24
|
size += buffer.length;
|
|
20
25
|
if (size > limit) {
|
|
21
|
-
|
|
22
|
-
|
|
26
|
+
exceeded = true;
|
|
27
|
+
chunks.length = 0;
|
|
28
|
+
continue;
|
|
23
29
|
}
|
|
24
30
|
chunks.push(buffer);
|
|
25
31
|
}
|
|
32
|
+
if (exceeded)
|
|
33
|
+
throw new HttpBodyError(`Request body exceeds ${limit} bytes`, 413);
|
|
26
34
|
if (chunks.length === 0)
|
|
27
35
|
return undefined;
|
|
28
36
|
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
package/lib/project-files.js
CHANGED
|
@@ -5,6 +5,16 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
|
|
|
5
5
|
'src',
|
|
6
6
|
'plugins',
|
|
7
7
|
'client',
|
|
8
|
+
// Plugin Runtime convention directories (file-based discovery)
|
|
9
|
+
'commands',
|
|
10
|
+
'adapters',
|
|
11
|
+
'components',
|
|
12
|
+
'middlewares',
|
|
13
|
+
'tools',
|
|
14
|
+
'mcp',
|
|
15
|
+
'pages',
|
|
16
|
+
'skills',
|
|
17
|
+
'agents',
|
|
8
18
|
'package.json',
|
|
9
19
|
'tsconfig.json',
|
|
10
20
|
'zhin.config.yml',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhin.js/host-http",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.4",
|
|
4
4
|
"description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./lib/index.js",
|
|
@@ -17,13 +17,13 @@
|
|
|
17
17
|
"src"
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
|
-
"ws": "^8.21.
|
|
20
|
+
"ws": "^8.21.1",
|
|
21
21
|
"@zhin.js/console-protocol": "1.1.0",
|
|
22
|
-
"@zhin.js/
|
|
23
|
-
"@zhin.js/
|
|
22
|
+
"@zhin.js/plugin-runtime": "1.1.1",
|
|
23
|
+
"@zhin.js/logger": "1.0.75"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@types/node": "^26.1.
|
|
26
|
+
"@types/node": "^26.1.2",
|
|
27
27
|
"@types/ws": "^8.18.1",
|
|
28
28
|
"typescript": "^6.0.3"
|
|
29
29
|
},
|
|
@@ -85,6 +85,8 @@ export interface ConsoleRestPagesOptions {
|
|
|
85
85
|
type LogModelLike = {
|
|
86
86
|
select(...fields: string[]): LogSelectionLike;
|
|
87
87
|
delete(where: Record<string, unknown>): Promise<unknown>;
|
|
88
|
+
/** DB 侧 count 聚合(可选;缺失时降级为 select('id') 计数)。 */
|
|
89
|
+
count?(where?: Record<string, unknown>): Promise<number>;
|
|
88
90
|
};
|
|
89
91
|
|
|
90
92
|
type LogSelectionLike = PromiseLike<LogRow[]> & {
|
|
@@ -126,8 +128,9 @@ export function registerConsoleRestPages(
|
|
|
126
128
|
const base = normalizeBase(options.apiBase ?? '/api');
|
|
127
129
|
const fetchFn = options.fetchFn ?? fetch;
|
|
128
130
|
const pluginRegistryUrl = options.pluginRegistryUrl ?? 'https://zhin.js.org/plugins.json';
|
|
129
|
-
const npmRegistryUrl = (
|
|
130
|
-
.
|
|
131
|
+
const npmRegistryUrl = trimTrailingSlashes(
|
|
132
|
+
options.npmRegistryUrl ?? 'https://registry.npmmirror.com',
|
|
133
|
+
);
|
|
131
134
|
const disposers: Array<() => void> = [];
|
|
132
135
|
const route: typeof host.route = (method, path, handler, meta) => {
|
|
133
136
|
const dispose = host.route(method, path, handler, meta);
|
|
@@ -223,13 +226,20 @@ function registerLogsRoutes(
|
|
|
223
226
|
return;
|
|
224
227
|
}
|
|
225
228
|
|
|
226
|
-
|
|
229
|
+
// 计数走 DB 侧聚合(count);模型未实现 count 时降级为 select('id') 只取主键列
|
|
230
|
+
const countRows = async (where?: Record<string, unknown>): Promise<number> => {
|
|
231
|
+
if (typeof LogModel.count === 'function') return LogModel.count(where);
|
|
232
|
+
const selection = LogModel.select('id');
|
|
233
|
+
const rows = await (where ? selection.where(where) : selection);
|
|
234
|
+
return rows.length;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
const total = await countRows();
|
|
227
238
|
const levels = ['info', 'warn', 'error'];
|
|
228
239
|
const levelCounts: Record<string, number> = {};
|
|
229
240
|
|
|
230
241
|
for (const level of levels) {
|
|
231
|
-
|
|
232
|
-
levelCounts[level] = count.length;
|
|
242
|
+
levelCounts[level] = await countRows({ level });
|
|
233
243
|
}
|
|
234
244
|
|
|
235
245
|
const oldestLog = await LogModel.select('timestamp')
|
|
@@ -244,7 +254,7 @@ function registerLogsRoutes(
|
|
|
244
254
|
|
|
245
255
|
writeJson(response, 200, {
|
|
246
256
|
success: true,
|
|
247
|
-
data: { total
|
|
257
|
+
data: { total, byLevel: levelCounts, oldestTimestamp },
|
|
248
258
|
});
|
|
249
259
|
}, { summary: 'System log stats', tags: ['console', 'logs'] });
|
|
250
260
|
|
|
@@ -865,9 +875,15 @@ function decodePathParam(raw: string): string {
|
|
|
865
875
|
}
|
|
866
876
|
}
|
|
867
877
|
|
|
878
|
+
function trimTrailingSlashes(s: string): string {
|
|
879
|
+
let i = s.length;
|
|
880
|
+
while (i > 0 && s[i - 1] === '/') i--;
|
|
881
|
+
return s.slice(0, i);
|
|
882
|
+
}
|
|
883
|
+
|
|
868
884
|
function normalizeBase(value: string): string {
|
|
869
885
|
if (!value.startsWith('/')) return `/${value}`;
|
|
870
|
-
return value
|
|
886
|
+
return trimTrailingSlashes(value) || '/api';
|
|
871
887
|
}
|
|
872
888
|
|
|
873
889
|
function writeJson(response: ServerResponse, status: number, body: unknown): void {
|
|
@@ -67,18 +67,28 @@ interface ScheduleJobRow {
|
|
|
67
67
|
description?: string;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
interface InboxSelection {
|
|
71
|
+
where(query: Record<string, unknown>): InboxSelection;
|
|
72
|
+
/** 可选:DB 侧排序下推(wrapModel 链具备该能力,轻量 fake 可缺失)。 */
|
|
73
|
+
orderBy?(field: string, direction?: 'ASC' | 'DESC'): InboxSelection;
|
|
74
|
+
/** 可选:DB 侧 limit 下推。 */
|
|
75
|
+
limit?(count: number): InboxSelection;
|
|
76
|
+
then<TResult1 = Record<string, unknown>[], TResult2 = never>(
|
|
77
|
+
onfulfilled?: ((value: Record<string, unknown>[]) => TResult1 | PromiseLike<TResult1>) | null,
|
|
78
|
+
onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
|
|
79
|
+
): Promise<TResult1 | TResult2>;
|
|
80
|
+
}
|
|
81
|
+
|
|
70
82
|
interface InboxModel {
|
|
71
|
-
select():
|
|
72
|
-
where(query: Record<string, unknown>): Promise<Record<string, unknown>[]> | Record<string, unknown>[];
|
|
73
|
-
};
|
|
83
|
+
select(): InboxSelection;
|
|
74
84
|
update?(patch: Record<string, unknown>): {
|
|
75
85
|
where(query: Record<string, unknown>): Promise<unknown> | unknown;
|
|
76
86
|
};
|
|
77
87
|
}
|
|
78
88
|
|
|
79
89
|
interface EndpointManagementPort {
|
|
80
|
-
listFriends?(): Promise<readonly { user_id: number; nickname: string; remark: string }[]>;
|
|
81
|
-
listGroups?(): Promise<readonly { group_id: number; name: string }[]>;
|
|
90
|
+
listFriends?(): Promise<readonly { user_id: number | string; nickname: string; remark: string }[]>;
|
|
91
|
+
listGroups?(): Promise<readonly { group_id: number | string; name: string }[]>;
|
|
82
92
|
listChannels?(): Promise<readonly {
|
|
83
93
|
id: string;
|
|
84
94
|
name?: string;
|
|
@@ -297,11 +307,24 @@ async function readInboxRows(
|
|
|
297
307
|
ctx: ConsoleRpcExtendedCtx,
|
|
298
308
|
table: string,
|
|
299
309
|
where: Record<string, unknown>,
|
|
310
|
+
options?: {
|
|
311
|
+
/** DB 侧排序下推(模型不支持时由调用方内存排序兜底)。 */
|
|
312
|
+
orderBy?: { field: string; direction?: 'ASC' | 'DESC' };
|
|
313
|
+
/** DB 侧 limit 下推(模型不支持时由调用方内存分页兜底)。 */
|
|
314
|
+
limit?: number;
|
|
315
|
+
},
|
|
300
316
|
): Promise<{ rows: Record<string, unknown>[]; enabled: boolean }> {
|
|
301
317
|
const model = getInboxModel(ctx, table);
|
|
302
318
|
if (!model) return { rows: [], enabled: false };
|
|
303
319
|
try {
|
|
304
|
-
|
|
320
|
+
let selection: InboxSelection = model.select().where(where);
|
|
321
|
+
if (options?.orderBy && typeof selection.orderBy === 'function') {
|
|
322
|
+
selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
|
|
323
|
+
}
|
|
324
|
+
if (options?.limit != null && typeof selection.limit === 'function') {
|
|
325
|
+
selection = selection.limit(options.limit);
|
|
326
|
+
}
|
|
327
|
+
const rows = await selection;
|
|
305
328
|
return { rows: Array.isArray(rows) ? rows : [], enabled: true };
|
|
306
329
|
} catch {
|
|
307
330
|
// 表未创建 / 方言未启动等场景降级为空,不向上抛。
|
|
@@ -324,6 +347,10 @@ async function listInbox(
|
|
|
324
347
|
const { rows, enabled } = await readInboxRows(ctx, table, {
|
|
325
348
|
adapter,
|
|
326
349
|
endpoint_id: endpointId,
|
|
350
|
+
}, {
|
|
351
|
+
// sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
|
|
352
|
+
orderBy: { field: 'created_at', direction: 'DESC' },
|
|
353
|
+
limit: offset + limit,
|
|
327
354
|
});
|
|
328
355
|
const sorted = rows
|
|
329
356
|
.slice()
|
|
@@ -344,6 +371,8 @@ async function listPendingRequests(
|
|
|
344
371
|
const { rows, enabled } = await readInboxRows(ctx, TABLE_REQUEST, {
|
|
345
372
|
adapter,
|
|
346
373
|
endpoint_id: endpointId,
|
|
374
|
+
}, {
|
|
375
|
+
orderBy: { field: 'created_at', direction: 'ASC' },
|
|
347
376
|
});
|
|
348
377
|
const requests = rows
|
|
349
378
|
.filter((row) => Number(row.resolved ?? 0) === 0)
|
|
@@ -378,7 +407,10 @@ async function listInboxMessages(
|
|
|
378
407
|
where.channel_parent_type = parent.type;
|
|
379
408
|
where.channel_parent_id = parent.id;
|
|
380
409
|
}
|
|
381
|
-
const { rows, enabled } = await readInboxRows(ctx, TABLE_MESSAGE, where
|
|
410
|
+
const { rows, enabled } = await readInboxRows(ctx, TABLE_MESSAGE, where, {
|
|
411
|
+
// before_ts/before_id 过滤保留在内存(涉及双字段比较),排序下推 DB 侧
|
|
412
|
+
orderBy: { field: 'created_at', direction: 'DESC' },
|
|
413
|
+
});
|
|
382
414
|
const messages = rows
|
|
383
415
|
.filter((row) => (beforeTs == null || Number(row.created_at ?? 0) < beforeTs)
|
|
384
416
|
&& (beforeId == null || Number(row.id ?? 0) < beforeId))
|
package/src/http-host.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
type RouteMeta,
|
|
23
23
|
} from './openapi.js';
|
|
24
24
|
import { isDemoHttpAllowed } from './console-rpc.js';
|
|
25
|
+
import { HttpBodyError } from './json-body.js';
|
|
25
26
|
|
|
26
27
|
const logger = getLogger('HttpHost');
|
|
27
28
|
|
|
@@ -276,6 +277,14 @@ export function createHttpHost(options: HttpHostOptions = {}): HttpHost {
|
|
|
276
277
|
response.setHeader('X-Frame-Options', 'SAMEORIGIN');
|
|
277
278
|
await route.handler(request, response, url, auth.scope);
|
|
278
279
|
} catch (err) {
|
|
280
|
+
// 统一把请求体错误(400/413)映射回对应状态码,
|
|
281
|
+
// 避免个别端点未捕获时退化成 500 空响应。
|
|
282
|
+
if (err instanceof HttpBodyError) {
|
|
283
|
+
if (!response.headersSent) {
|
|
284
|
+
writeJson(response, err.statusCode, { success: false, error: err.message });
|
|
285
|
+
}
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
279
288
|
logger.warn(formatCompact({
|
|
280
289
|
op: 'http_handler_failed',
|
|
281
290
|
path: pathname,
|
package/src/json-body.ts
CHANGED
|
@@ -21,15 +21,21 @@ export async function readJsonBody<T = unknown>(
|
|
|
21
21
|
const limit = options.limit ?? 1_048_576;
|
|
22
22
|
const chunks: Buffer[] = [];
|
|
23
23
|
let size = 0;
|
|
24
|
+
let exceeded = false;
|
|
24
25
|
for await (const chunk of request) {
|
|
26
|
+
// 超限后继续 drain 到 end:提前 break 会 destroy 底层 socket,
|
|
27
|
+
// 客户端将永远收不到后面的 413 响应。
|
|
28
|
+
if (exceeded) continue;
|
|
25
29
|
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
26
30
|
size += buffer.length;
|
|
27
31
|
if (size > limit) {
|
|
28
|
-
|
|
29
|
-
|
|
32
|
+
exceeded = true;
|
|
33
|
+
chunks.length = 0;
|
|
34
|
+
continue;
|
|
30
35
|
}
|
|
31
36
|
chunks.push(buffer);
|
|
32
37
|
}
|
|
38
|
+
if (exceeded) throw new HttpBodyError(`Request body exceeds ${limit} bytes`, 413);
|
|
33
39
|
if (chunks.length === 0) return undefined;
|
|
34
40
|
const raw = Buffer.concat(chunks).toString('utf8').trim();
|
|
35
41
|
if (!raw) return undefined;
|
package/src/project-files.ts
CHANGED
|
@@ -6,6 +6,16 @@ export const FILE_MANAGER_ALLOWED = Object.freeze([
|
|
|
6
6
|
'src',
|
|
7
7
|
'plugins',
|
|
8
8
|
'client',
|
|
9
|
+
// Plugin Runtime convention directories (file-based discovery)
|
|
10
|
+
'commands',
|
|
11
|
+
'adapters',
|
|
12
|
+
'components',
|
|
13
|
+
'middlewares',
|
|
14
|
+
'tools',
|
|
15
|
+
'mcp',
|
|
16
|
+
'pages',
|
|
17
|
+
'skills',
|
|
18
|
+
'agents',
|
|
9
19
|
'package.json',
|
|
10
20
|
'tsconfig.json',
|
|
11
21
|
'zhin.config.yml',
|