@zhin.js/host-http 1.0.2 → 1.0.3

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 CHANGED
@@ -45,5 +45,6 @@ http:
45
45
  base: /api # auth prefix
46
46
  ```
47
47
 
48
- Still deferred to later host migration: full `@zhin.js/host-api` management-plane REST/RPC/SSE
49
- and retiring the legacy Koa `@zhin.js/host-router` stack.
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.
@@ -99,12 +99,19 @@ function registerLogsRoutes(route, base, ctx) {
99
99
  });
100
100
  return;
101
101
  }
102
- const total = await LogModel.select();
102
+ // 计数走 DB 侧聚合(count);模型未实现 count 时降级为 select('id') 只取主键列
103
+ const countRows = async (where) => {
104
+ if (typeof LogModel.count === 'function')
105
+ return LogModel.count(where);
106
+ const selection = LogModel.select('id');
107
+ const rows = await (where ? selection.where(where) : selection);
108
+ return rows.length;
109
+ };
110
+ const total = await countRows();
103
111
  const levels = ['info', 'warn', 'error'];
104
112
  const levelCounts = {};
105
113
  for (const level of levels) {
106
- const count = await LogModel.select().where({ level });
107
- levelCounts[level] = count.length;
114
+ levelCounts[level] = await countRows({ level });
108
115
  }
109
116
  const oldestLog = await LogModel.select('timestamp')
110
117
  .orderBy('timestamp', 'ASC')
@@ -116,7 +123,7 @@ function registerLogsRoutes(route, base, ctx) {
116
123
  : null;
117
124
  writeJson(response, 200, {
118
125
  success: true,
119
- data: { total: total.length, byLevel: levelCounts, oldestTimestamp },
126
+ data: { total, byLevel: levelCounts, oldestTimestamp },
120
127
  });
121
128
  }, { summary: 'System log stats', tags: ['console', 'logs'] });
122
129
  route('POST', `${base}/logs/cleanup`, async (request, response, _url, authScope) => {
@@ -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
- const rows = await model.select().where(where);
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
- request.destroy();
22
- throw new HttpBodyError(`Request body exceeds ${limit} bytes`, 413);
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();
@@ -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.2",
3
+ "version": "1.0.3",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -20,7 +20,7 @@
20
20
  "ws": "^8.21.0",
21
21
  "@zhin.js/console-protocol": "1.1.0",
22
22
  "@zhin.js/logger": "1.0.75",
23
- "@zhin.js/plugin-runtime": "1.1.0"
23
+ "@zhin.js/plugin-runtime": "1.1.1"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/node": "^26.1.0",
@@ -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[]> & {
@@ -223,13 +225,20 @@ function registerLogsRoutes(
223
225
  return;
224
226
  }
225
227
 
226
- const total = await LogModel.select();
228
+ // 计数走 DB 侧聚合(count);模型未实现 count 时降级为 select('id') 只取主键列
229
+ const countRows = async (where?: Record<string, unknown>): Promise<number> => {
230
+ if (typeof LogModel.count === 'function') return LogModel.count(where);
231
+ const selection = LogModel.select('id');
232
+ const rows = await (where ? selection.where(where) : selection);
233
+ return rows.length;
234
+ };
235
+
236
+ const total = await countRows();
227
237
  const levels = ['info', 'warn', 'error'];
228
238
  const levelCounts: Record<string, number> = {};
229
239
 
230
240
  for (const level of levels) {
231
- const count = await LogModel.select().where({ level });
232
- levelCounts[level] = count.length;
241
+ levelCounts[level] = await countRows({ level });
233
242
  }
234
243
 
235
244
  const oldestLog = await LogModel.select('timestamp')
@@ -244,7 +253,7 @@ function registerLogsRoutes(
244
253
 
245
254
  writeJson(response, 200, {
246
255
  success: true,
247
- data: { total: total.length, byLevel: levelCounts, oldestTimestamp },
256
+ data: { total, byLevel: levelCounts, oldestTimestamp },
248
257
  });
249
258
  }, { summary: 'System log stats', tags: ['console', 'logs'] });
250
259
 
@@ -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
- const rows = await model.select().where(where);
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
- request.destroy();
29
- throw new HttpBodyError(`Request body exceeds ${limit} bytes`, 413);
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;
@@ -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',