@zhin.js/host-http 1.0.11 → 1.0.12

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.
@@ -1,13 +1,27 @@
1
1
  import type { ServerResponse } from 'node:http';
2
+ import type { ConsoleEventEnvelope, ConsoleEventHistoryPage, ConsoleEventHistoryQuery, ConsoleEventPayloadMap, KnownConsoleEventType } from '@zhin.js/console-protocol';
2
3
  /**
3
4
  * Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
4
5
  * 单进程内 fan-out:`publish` 向所有已订阅的 SSE response 写帧;
5
6
  * write 失败或连接断开自动摘除订阅。
6
7
  */
8
+ export interface ConsoleEventSubscriptionOptions {
9
+ readonly runtimeId?: string;
10
+ readonly after?: number;
11
+ }
7
12
  export interface ConsoleEventHub {
8
- publish(type: string, data: unknown): void;
9
- /** 订阅事件流;返回 unsubscribe。连接断开时也会自动摘除。 */
10
- subscribe(response: ServerResponse): () => void;
13
+ publish<Type extends string>(type: Type, data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown): ConsoleEventEnvelope<Type, Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown>;
14
+ history(query?: ConsoleEventHistoryQuery): ConsoleEventHistoryPage;
15
+ /** Atomically replay the resumable suffix, then join live fan-out. */
16
+ subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
17
+ readonly runtimeId: string;
11
18
  readonly subscriberCount: number;
12
19
  }
13
- export declare function createConsoleEventHub(): ConsoleEventHub;
20
+ export interface ConsoleEventHubOptions {
21
+ readonly runtimeId?: string;
22
+ readonly historyLimit?: number;
23
+ readonly historyByteLimit?: number;
24
+ readonly historyPageByteLimit?: number;
25
+ }
26
+ export declare function createConsoleEventHub(options?: ConsoleEventHubOptions): ConsoleEventHub;
27
+ export declare function serializeConsoleEvent(event: ConsoleEventEnvelope): string;
@@ -1,33 +1,145 @@
1
- export function createConsoleEventHub() {
1
+ import { randomUUID } from 'node:crypto';
2
+ const DEFAULT_HISTORY_LIMIT = 256;
3
+ const DEFAULT_PAGE_LIMIT = 200;
4
+ const MAX_PAGE_LIMIT = 500;
5
+ const MAX_EVENT_BYTES = 64 * 1024;
6
+ const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
7
+ const DEFAULT_HISTORY_PAGE_BYTE_LIMIT = 1024 * 1024;
8
+ export function createConsoleEventHub(options = {}) {
2
9
  let nextId = 0;
10
+ const runtimeId = options.runtimeId ?? randomUUID();
11
+ const historyLimit = positiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT);
12
+ const historyByteLimit = positiveInteger(options.historyByteLimit, DEFAULT_HISTORY_BYTE_LIMIT);
13
+ const historyPageByteLimit = positiveInteger(options.historyPageByteLimit, DEFAULT_HISTORY_PAGE_BYTE_LIMIT);
14
+ const journal = [];
15
+ let journalBytes = 0;
3
16
  const subscribers = new Set();
4
17
  const remove = (response) => {
5
18
  subscribers.delete(response);
6
19
  };
20
+ const write = (response, entry) => {
21
+ let failed = false;
22
+ const accepted = response.write(entry.frame, (error) => {
23
+ if (error) {
24
+ failed = true;
25
+ remove(response);
26
+ }
27
+ });
28
+ if (failed)
29
+ throw new Error('Console event subscriber rejected replay');
30
+ if (!accepted) {
31
+ remove(response);
32
+ response.destroy(new Error('Console event subscriber backpressure limit reached'));
33
+ throw new Error('Console event subscriber backpressure limit reached');
34
+ }
35
+ };
7
36
  return {
37
+ runtimeId,
8
38
  get subscriberCount() {
9
39
  return subscribers.size;
10
40
  },
11
41
  publish(type, data) {
12
- nextId += 1;
13
- const frame = `id: ${nextId}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
42
+ const snapshot = jsonSnapshot(data);
43
+ const event = Object.freeze({
44
+ runtimeId,
45
+ eventId: ++nextId,
46
+ type,
47
+ data: snapshot,
48
+ timestamp: Date.now(),
49
+ });
50
+ const frame = serializeConsoleEvent(event);
51
+ const entry = Object.freeze({ event, frame, bytes: Buffer.byteLength(frame, 'utf8') });
52
+ journal.push(entry);
53
+ journalBytes += entry.bytes;
54
+ while (journal.length > historyLimit || journalBytes > historyByteLimit) {
55
+ journalBytes -= journal.shift()?.bytes ?? 0;
56
+ }
14
57
  for (const response of [...subscribers]) {
15
58
  try {
16
- response.write(frame, (error) => {
17
- if (error)
18
- remove(response);
19
- });
59
+ write(response, entry);
20
60
  }
21
61
  catch {
22
62
  remove(response);
23
63
  }
24
64
  }
65
+ return event;
25
66
  },
26
- subscribe(response) {
27
- subscribers.add(response);
67
+ history(query = {}) {
68
+ const requestedAfter = nonNegativeInteger(query.after, 0);
69
+ const sameRuntime = !query.runtimeId || query.runtimeId === runtimeId;
70
+ const cursorAhead = requestedAfter > nextId;
71
+ const after = sameRuntime && !cursorAhead ? requestedAfter : 0;
72
+ const limit = Math.min(MAX_PAGE_LIMIT, positiveInteger(query.limit, DEFAULT_PAGE_LIMIT));
73
+ const oldestAvailableEventId = journal[0]?.event.eventId ?? null;
74
+ const latestEventId = nextId;
75
+ const gap = !sameRuntime || cursorAhead
76
+ || (oldestAvailableEventId !== null
77
+ ? after < oldestAvailableEventId - 1
78
+ : after < latestEventId);
79
+ const available = journal.filter((entry) => entry.event.eventId > after);
80
+ const selected = [];
81
+ let selectedBytes = 0;
82
+ for (const entry of available) {
83
+ if (selected.length >= limit)
84
+ break;
85
+ if (selected.length > 0 && selectedBytes + entry.bytes > historyPageByteLimit)
86
+ break;
87
+ selected.push(entry.event);
88
+ selectedBytes += entry.bytes;
89
+ }
90
+ const items = Object.freeze(selected);
91
+ const nextAfter = items.at(-1)?.eventId ?? after;
92
+ return Object.freeze({
93
+ runtimeId,
94
+ items,
95
+ oldestAvailableEventId,
96
+ latestEventId,
97
+ nextAfter,
98
+ hasMore: available.length > items.length,
99
+ gap,
100
+ });
101
+ },
102
+ subscribe(response, options = {}) {
103
+ // Replay and subscription are synchronous relative to publish(), closing
104
+ // the race between HTTP recovery and the live stream hand-off.
105
+ const requestedAfter = nonNegativeInteger(options.after, 0);
106
+ const after = options.runtimeId && options.runtimeId !== runtimeId
107
+ || requestedAfter > nextId
108
+ ? 0
109
+ : requestedAfter;
110
+ try {
111
+ for (const entry of journal) {
112
+ if (entry.event.eventId > after)
113
+ write(response, entry);
114
+ }
115
+ subscribers.add(response);
116
+ }
117
+ catch {
118
+ remove(response);
119
+ }
28
120
  response.once('close', () => remove(response));
29
121
  response.once('error', () => remove(response));
30
122
  return () => remove(response);
31
123
  },
32
124
  };
33
125
  }
126
+ export function serializeConsoleEvent(event) {
127
+ return `id: ${event.eventId}\nevent: ${event.type}\nruntime: ${event.runtimeId}\ntimestamp: ${event.timestamp}\ndata: ${JSON.stringify(event.data)}\n\n`;
128
+ }
129
+ function positiveInteger(value, fallback) {
130
+ const parsed = Number(value);
131
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
132
+ }
133
+ function nonNegativeInteger(value, fallback) {
134
+ const parsed = Number(value);
135
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
136
+ }
137
+ function jsonSnapshot(value) {
138
+ const encoded = JSON.stringify(value ?? null);
139
+ if (encoded === undefined)
140
+ throw new TypeError('Console event data must be JSON serializable');
141
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_EVENT_BYTES) {
142
+ throw new RangeError(`Console event data exceeds ${MAX_EVENT_BYTES} bytes`);
143
+ }
144
+ return JSON.parse(encoded);
145
+ }
@@ -21,6 +21,7 @@ import { assertDemoConsoleRpcAllowed, normalizeConsoleRpcType, } from '@zhin.js/
21
21
  const TABLE_MESSAGE = 'unified_inbox_message';
22
22
  const TABLE_REQUEST = 'unified_inbox_request';
23
23
  const TABLE_NOTICE = 'unified_inbox_notice';
24
+ const MAX_INBOX_OFFSET = 10_000;
24
25
  const CRON_NOT_WIRED = '持久化调度未接线:当前 Plugin Runtime ScheduleHost 仅支持插件注册的内存任务(list),' +
25
26
  '不支持 add/remove/pause/resume(需要 @zhin.js/agent 持久化调度引擎)';
26
27
  const CONSUMED_NOT_WIRED = '收件箱已读标记未接线:unified_inbox_request/notice 表未注册或 DatabaseHost 未启动';
@@ -330,21 +331,30 @@ function getInboxModel(ctx, table) {
330
331
  async function readInboxRows(ctx, table, where, options) {
331
332
  const model = getInboxModel(ctx, table);
332
333
  if (!model)
333
- return { rows: [], enabled: false };
334
+ return { rows: [], enabled: false, offsetApplied: false };
334
335
  try {
335
336
  let selection = model.select().where(where);
337
+ let orderApplied = options?.orderBy == null;
336
338
  if (options?.orderBy && typeof selection.orderBy === 'function') {
337
339
  selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
340
+ orderApplied = true;
338
341
  }
339
- if (options?.limit != null && typeof selection.limit === 'function') {
340
- selection = selection.limit(options.limit);
342
+ const offset = options?.offset ?? 0;
343
+ const offsetSelection = orderApplied ? selection.offset : undefined;
344
+ const canApplyOffset = typeof offsetSelection === 'function';
345
+ const offsetApplied = offset === 0 || canApplyOffset;
346
+ if (offset > 0 && canApplyOffset) {
347
+ selection = offsetSelection.call(selection, offset);
348
+ }
349
+ if (options?.limit != null && orderApplied && typeof selection.limit === 'function') {
350
+ selection = selection.limit(options.limit + (offsetApplied ? 0 : offset));
341
351
  }
342
352
  const rows = await selection;
343
- return { rows: Array.isArray(rows) ? rows : [], enabled: true };
353
+ return { rows: Array.isArray(rows) ? rows : [], enabled: true, offsetApplied };
344
354
  }
345
355
  catch {
346
356
  // 表未创建 / 方言未启动等场景降级为空,不向上抛。
347
- return { rows: [], enabled: false };
357
+ return { rows: [], enabled: false, offsetApplied: false };
348
358
  }
349
359
  }
350
360
  async function listInbox(d, ctx, table, key, mapRow) {
@@ -352,20 +362,24 @@ async function listInbox(d, ctx, table, key, mapRow) {
352
362
  const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
353
363
  if (!adapter || !endpointKey)
354
364
  return { error: '$adapter and $endpoint required' };
355
- const limit = Math.min(numField(d, 30, '$limit', 'limit'), 100);
356
- const offset = Math.max(0, numField(d, 0, '$offset', 'offset'));
357
- const { rows, enabled } = await readInboxRows(ctx, table, {
365
+ const limit = boundedIntegerField(d, 30, 1, 100, '$limit', 'limit');
366
+ const offset = boundedIntegerField(d, 0, 0, MAX_INBOX_OFFSET, '$offset', 'offset');
367
+ const unreadOnly = table === TABLE_NOTICE
368
+ && boolField(d, false, '$unread_only', 'unreadOnly', 'unread_only');
369
+ const { rows, enabled, offsetApplied } = await readInboxRows(ctx, table, {
358
370
  adapter,
359
371
  endpoint_id: endpointKey,
372
+ ...(unreadOnly ? { consumed: 0 } : {}),
360
373
  }, {
361
374
  // sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
362
375
  orderBy: { field: 'created_at', direction: 'DESC' },
363
- limit: offset + limit,
376
+ limit,
377
+ offset,
364
378
  });
365
379
  const sorted = rows
366
380
  .slice()
367
381
  .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
368
- .slice(offset, offset + limit)
382
+ .slice(offsetApplied ? 0 : offset, (offsetApplied ? 0 : offset) + limit)
369
383
  .map(mapRow);
370
384
  return { data: { [key]: sorted, inboxEnabled: enabled } };
371
385
  }
@@ -422,7 +436,7 @@ async function listInboxMessages(d, ctx) {
422
436
  if (!adapter || !endpointKey || !channelId || !channelType) {
423
437
  return { error: '$adapter, $endpoint, $channel_id, $channel_type required' };
424
438
  }
425
- const limit = Math.min(numField(d, 50, '$limit', 'limit'), 100);
439
+ const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
426
440
  const beforeTs = optionalNum(d, '$before_ts', 'beforeTs', 'before_ts');
427
441
  const beforeId = optionalNum(d, '$before_id', 'beforeId', 'before_id');
428
442
  const parent = normalizeParent(d.$parent ?? d.parent);
@@ -489,16 +503,18 @@ function mapRequestRow(row) {
489
503
  function mapNoticeRow(row) {
490
504
  const actorId = row.actor_id ?? undefined;
491
505
  const actorName = row.actor_name ?? undefined;
492
- const sceneId = row.scene_id;
493
- const sceneType = row.scene_type ?? undefined;
506
+ const sceneId = String(row.scene_id ?? '');
507
+ const sceneType = row.scene_type == null ? undefined : String(row.scene_type);
494
508
  return {
495
509
  id: row.id,
496
510
  platform_notice_id: row.platform_notice_id,
497
511
  type: row.type,
512
+ noticeType: row.type,
498
513
  scene_type: sceneType,
499
514
  scene_id: sceneId,
500
515
  channel_id: sceneId,
501
516
  channel_type: sceneType,
517
+ channel: { id: sceneId, type: sceneType ?? '' },
502
518
  sub_type: row.sub_type ?? undefined,
503
519
  actor_id: actorId,
504
520
  actor_name: actorName,
@@ -510,6 +526,8 @@ function mapNoticeRow(row) {
510
526
  payload: row.payload,
511
527
  created_at: row.created_at,
512
528
  timestamp: row.created_at,
529
+ consumed: row.consumed,
530
+ consumed_at: row.consumed_at ?? undefined,
513
531
  };
514
532
  }
515
533
  // ---------------------------------------------------------------- consumed 标记
@@ -725,6 +743,20 @@ function numField(d, fallback, ...keys) {
725
743
  }
726
744
  return fallback;
727
745
  }
746
+ function boundedIntegerField(d, fallback, minimum, maximum, ...keys) {
747
+ const value = numField(d, fallback, ...keys);
748
+ if (!Number.isFinite(value))
749
+ return fallback;
750
+ return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
751
+ }
752
+ function boolField(d, fallback, ...keys) {
753
+ for (const key of keys) {
754
+ const value = d[key];
755
+ if (typeof value === 'boolean')
756
+ return value;
757
+ }
758
+ return fallback;
759
+ }
728
760
  function optionalNum(d, ...keys) {
729
761
  for (const key of keys) {
730
762
  if (d[key] == null)
@@ -699,6 +699,10 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
699
699
  const m = method.toUpperCase();
700
700
  if (m === 'GET' && pathname === `${normalizedBase}/events`)
701
701
  return true;
702
+ if (m === 'GET' && pathname === `${normalizedBase}/events/history`)
703
+ return true;
704
+ if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length)))
705
+ return true;
702
706
  if (m === 'POST' && pathname === `${normalizedBase}/console/request`)
703
707
  return true;
704
708
  // Read-only dashboard / plugin inspection routes (legacy host-api parity).
@@ -711,3 +715,12 @@ export function isDemoHttpAllowed(method, pathname, apiBase) {
711
715
  return true;
712
716
  return false;
713
717
  }
718
+ const DEMO_INTROSPECTION_PATHS = new Set([
719
+ '/introspection/commands',
720
+ '/introspection/middlewares',
721
+ '/introspection/components',
722
+ '/introspection/endpoints',
723
+ '/introspection/bindings',
724
+ '/introspection/tools',
725
+ '/introspection/mcp',
726
+ ]);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zhin.js/host-http",
3
- "version": "1.0.11",
3
+ "version": "1.0.12",
4
4
  "description": "Minimal HTTP and WebSocket host for Plugin Runtime (no Koa)",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -18,7 +18,7 @@
18
18
  ],
19
19
  "dependencies": {
20
20
  "ws": "^8.21.1",
21
- "@zhin.js/console-protocol": "1.1.3",
21
+ "@zhin.js/console-protocol": "1.1.4",
22
22
  "@zhin.js/host-http-contract": "1.0.1",
23
23
  "@zhin.js/logger": "1.0.76",
24
24
  "@zhin.js/plugin-runtime": "1.1.7"
@@ -1,47 +1,205 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import type { ServerResponse } from 'node:http';
3
+ import type {
4
+ ConsoleEventEnvelope,
5
+ ConsoleEventHistoryPage,
6
+ ConsoleEventHistoryQuery,
7
+ ConsoleEventPayloadMap,
8
+ KnownConsoleEventType,
9
+ } from '@zhin.js/console-protocol';
2
10
 
3
11
  /**
4
12
  * Console 实时事件枢纽(Plugin Runtime Host 的 `/api/events` SSE 事件源)。
5
13
  * 单进程内 fan-out:`publish` 向所有已订阅的 SSE response 写帧;
6
14
  * write 失败或连接断开自动摘除订阅。
7
15
  */
16
+ export interface ConsoleEventSubscriptionOptions {
17
+ readonly runtimeId?: string;
18
+ readonly after?: number;
19
+ }
20
+
8
21
  export interface ConsoleEventHub {
9
- publish(type: string, data: unknown): void;
10
- /** 订阅事件流;返回 unsubscribe。连接断开时也会自动摘除。 */
11
- subscribe(response: ServerResponse): () => void;
22
+ publish<Type extends string>(
23
+ type: Type,
24
+ data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown,
25
+ ): ConsoleEventEnvelope<
26
+ Type,
27
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
28
+ >;
29
+ history(query?: ConsoleEventHistoryQuery): ConsoleEventHistoryPage;
30
+ /** Atomically replay the resumable suffix, then join live fan-out. */
31
+ subscribe(response: ServerResponse, options?: ConsoleEventSubscriptionOptions): () => void;
32
+ readonly runtimeId: string;
12
33
  readonly subscriberCount: number;
13
34
  }
14
35
 
15
- export function createConsoleEventHub(): ConsoleEventHub {
36
+ export interface ConsoleEventHubOptions {
37
+ readonly runtimeId?: string;
38
+ readonly historyLimit?: number;
39
+ readonly historyByteLimit?: number;
40
+ readonly historyPageByteLimit?: number;
41
+ }
42
+
43
+ const DEFAULT_HISTORY_LIMIT = 256;
44
+ const DEFAULT_PAGE_LIMIT = 200;
45
+ const MAX_PAGE_LIMIT = 500;
46
+ const MAX_EVENT_BYTES = 64 * 1024;
47
+ const DEFAULT_HISTORY_BYTE_LIMIT = 8 * 1024 * 1024;
48
+ const DEFAULT_HISTORY_PAGE_BYTE_LIMIT = 1024 * 1024;
49
+
50
+ interface JournalEntry {
51
+ readonly event: ConsoleEventEnvelope;
52
+ readonly frame: string;
53
+ readonly bytes: number;
54
+ }
55
+
56
+ export function createConsoleEventHub(options: ConsoleEventHubOptions = {}): ConsoleEventHub {
16
57
  let nextId = 0;
58
+ const runtimeId = options.runtimeId ?? randomUUID();
59
+ const historyLimit = positiveInteger(options.historyLimit, DEFAULT_HISTORY_LIMIT);
60
+ const historyByteLimit = positiveInteger(options.historyByteLimit, DEFAULT_HISTORY_BYTE_LIMIT);
61
+ const historyPageByteLimit = positiveInteger(
62
+ options.historyPageByteLimit,
63
+ DEFAULT_HISTORY_PAGE_BYTE_LIMIT,
64
+ );
65
+ const journal: JournalEntry[] = [];
66
+ let journalBytes = 0;
17
67
  const subscribers = new Set<ServerResponse>();
18
68
 
19
69
  const remove = (response: ServerResponse): void => {
20
70
  subscribers.delete(response);
21
71
  };
22
72
 
73
+ const write = (response: ServerResponse, entry: JournalEntry): void => {
74
+ let failed = false;
75
+ const accepted = response.write(entry.frame, (error) => {
76
+ if (error) {
77
+ failed = true;
78
+ remove(response);
79
+ }
80
+ });
81
+ if (failed) throw new Error('Console event subscriber rejected replay');
82
+ if (!accepted) {
83
+ remove(response);
84
+ response.destroy(new Error('Console event subscriber backpressure limit reached'));
85
+ throw new Error('Console event subscriber backpressure limit reached');
86
+ }
87
+ };
88
+
23
89
  return {
90
+ runtimeId,
24
91
  get subscriberCount() {
25
92
  return subscribers.size;
26
93
  },
27
- publish(type, data) {
28
- nextId += 1;
29
- const frame = `id: ${nextId}\nevent: ${type}\ndata: ${JSON.stringify(data)}\n\n`;
94
+ publish<Type extends string>(
95
+ type: Type,
96
+ data: Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown,
97
+ ): ConsoleEventEnvelope<
98
+ Type,
99
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
100
+ > {
101
+ const snapshot = jsonSnapshot(data);
102
+ const event = Object.freeze({
103
+ runtimeId,
104
+ eventId: ++nextId,
105
+ type,
106
+ data: snapshot,
107
+ timestamp: Date.now(),
108
+ });
109
+ const frame = serializeConsoleEvent(event);
110
+ const entry = Object.freeze({ event, frame, bytes: Buffer.byteLength(frame, 'utf8') });
111
+ journal.push(entry);
112
+ journalBytes += entry.bytes;
113
+ while (journal.length > historyLimit || journalBytes > historyByteLimit) {
114
+ journalBytes -= journal.shift()?.bytes ?? 0;
115
+ }
30
116
  for (const response of [...subscribers]) {
31
117
  try {
32
- response.write(frame, (error) => {
33
- if (error) remove(response);
34
- });
118
+ write(response, entry);
35
119
  } catch {
36
120
  remove(response);
37
121
  }
38
122
  }
123
+ return event as ConsoleEventEnvelope<
124
+ Type,
125
+ Type extends KnownConsoleEventType ? ConsoleEventPayloadMap[Type] : unknown
126
+ >;
39
127
  },
40
- subscribe(response) {
41
- subscribers.add(response);
128
+ history(query = {}): ConsoleEventHistoryPage {
129
+ const requestedAfter = nonNegativeInteger(query.after, 0);
130
+ const sameRuntime = !query.runtimeId || query.runtimeId === runtimeId;
131
+ const cursorAhead = requestedAfter > nextId;
132
+ const after = sameRuntime && !cursorAhead ? requestedAfter : 0;
133
+ const limit = Math.min(MAX_PAGE_LIMIT, positiveInteger(query.limit, DEFAULT_PAGE_LIMIT));
134
+ const oldestAvailableEventId = journal[0]?.event.eventId ?? null;
135
+ const latestEventId = nextId;
136
+ const gap = !sameRuntime || cursorAhead
137
+ || (oldestAvailableEventId !== null
138
+ ? after < oldestAvailableEventId - 1
139
+ : after < latestEventId);
140
+ const available = journal.filter((entry) => entry.event.eventId > after);
141
+ const selected: ConsoleEventEnvelope[] = [];
142
+ let selectedBytes = 0;
143
+ for (const entry of available) {
144
+ if (selected.length >= limit) break;
145
+ if (selected.length > 0 && selectedBytes + entry.bytes > historyPageByteLimit) break;
146
+ selected.push(entry.event);
147
+ selectedBytes += entry.bytes;
148
+ }
149
+ const items = Object.freeze(selected);
150
+ const nextAfter = items.at(-1)?.eventId ?? after;
151
+ return Object.freeze({
152
+ runtimeId,
153
+ items,
154
+ oldestAvailableEventId,
155
+ latestEventId,
156
+ nextAfter,
157
+ hasMore: available.length > items.length,
158
+ gap,
159
+ });
160
+ },
161
+ subscribe(response, options = {}) {
162
+ // Replay and subscription are synchronous relative to publish(), closing
163
+ // the race between HTTP recovery and the live stream hand-off.
164
+ const requestedAfter = nonNegativeInteger(options.after, 0);
165
+ const after = options.runtimeId && options.runtimeId !== runtimeId
166
+ || requestedAfter > nextId
167
+ ? 0
168
+ : requestedAfter;
169
+ try {
170
+ for (const entry of journal) {
171
+ if (entry.event.eventId > after) write(response, entry);
172
+ }
173
+ subscribers.add(response);
174
+ } catch {
175
+ remove(response);
176
+ }
42
177
  response.once('close', () => remove(response));
43
178
  response.once('error', () => remove(response));
44
179
  return () => remove(response);
45
180
  },
46
181
  };
47
182
  }
183
+
184
+ export function serializeConsoleEvent(event: ConsoleEventEnvelope): string {
185
+ return `id: ${event.eventId}\nevent: ${event.type}\nruntime: ${event.runtimeId}\ntimestamp: ${event.timestamp}\ndata: ${JSON.stringify(event.data)}\n\n`;
186
+ }
187
+
188
+ function positiveInteger(value: unknown, fallback: number): number {
189
+ const parsed = Number(value);
190
+ return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : fallback;
191
+ }
192
+
193
+ function nonNegativeInteger(value: unknown, fallback: number): number {
194
+ const parsed = Number(value);
195
+ return Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : fallback;
196
+ }
197
+
198
+ function jsonSnapshot(value: unknown): unknown {
199
+ const encoded = JSON.stringify(value ?? null);
200
+ if (encoded === undefined) throw new TypeError('Console event data must be JSON serializable');
201
+ if (Buffer.byteLength(encoded, 'utf8') > MAX_EVENT_BYTES) {
202
+ throw new RangeError(`Console event data exceeds ${MAX_EVENT_BYTES} bytes`);
203
+ }
204
+ return JSON.parse(encoded) as unknown;
205
+ }
@@ -26,6 +26,7 @@ import {
26
26
  const TABLE_MESSAGE = 'unified_inbox_message';
27
27
  const TABLE_REQUEST = 'unified_inbox_request';
28
28
  const TABLE_NOTICE = 'unified_inbox_notice';
29
+ const MAX_INBOX_OFFSET = 10_000;
29
30
 
30
31
  export interface ConsoleRpcExtendedCtx {
31
32
  /** full scope 才允许写操作;demo scope 只放行只读 RPC。 */
@@ -131,6 +132,8 @@ interface InboxSelection {
131
132
  orderBy?(field: string, direction?: 'ASC' | 'DESC'): InboxSelection;
132
133
  /** 可选:DB 侧 limit 下推。 */
133
134
  limit?(count: number): InboxSelection;
135
+ /** 可选:DB 侧 offset 下推。 */
136
+ offset?(count: number): InboxSelection;
134
137
  then<TResult1 = Record<string, unknown>[], TResult2 = never>(
135
138
  onfulfilled?: ((value: Record<string, unknown>[]) => TResult1 | PromiseLike<TResult1>) | null,
136
139
  onrejected?: ((reason: unknown) => TResult2 | PromiseLike<TResult2>) | null,
@@ -509,23 +512,34 @@ async function readInboxRows(
509
512
  orderBy?: { field: string; direction?: 'ASC' | 'DESC' };
510
513
  /** DB 侧 limit 下推(模型不支持时由调用方内存分页兜底)。 */
511
514
  limit?: number;
515
+ /** DB 侧 offset 下推(模型不支持时由调用方内存分页兜底)。 */
516
+ offset?: number;
512
517
  },
513
- ): Promise<{ rows: Record<string, unknown>[]; enabled: boolean }> {
518
+ ): Promise<{ rows: Record<string, unknown>[]; enabled: boolean; offsetApplied: boolean }> {
514
519
  const model = getInboxModel(ctx, table);
515
- if (!model) return { rows: [], enabled: false };
520
+ if (!model) return { rows: [], enabled: false, offsetApplied: false };
516
521
  try {
517
522
  let selection: InboxSelection = model.select().where(where);
523
+ let orderApplied = options?.orderBy == null;
518
524
  if (options?.orderBy && typeof selection.orderBy === 'function') {
519
525
  selection = selection.orderBy(options.orderBy.field, options.orderBy.direction ?? 'DESC');
526
+ orderApplied = true;
520
527
  }
521
- if (options?.limit != null && typeof selection.limit === 'function') {
522
- selection = selection.limit(options.limit);
528
+ const offset = options?.offset ?? 0;
529
+ const offsetSelection = orderApplied ? selection.offset : undefined;
530
+ const canApplyOffset = typeof offsetSelection === 'function';
531
+ const offsetApplied = offset === 0 || canApplyOffset;
532
+ if (offset > 0 && canApplyOffset) {
533
+ selection = offsetSelection.call(selection, offset);
534
+ }
535
+ if (options?.limit != null && orderApplied && typeof selection.limit === 'function') {
536
+ selection = selection.limit(options.limit + (offsetApplied ? 0 : offset));
523
537
  }
524
538
  const rows = await selection;
525
- return { rows: Array.isArray(rows) ? rows : [], enabled: true };
539
+ return { rows: Array.isArray(rows) ? rows : [], enabled: true, offsetApplied };
526
540
  } catch {
527
541
  // 表未创建 / 方言未启动等场景降级为空,不向上抛。
528
- return { rows: [], enabled: false };
542
+ return { rows: [], enabled: false, offsetApplied: false };
529
543
  }
530
544
  }
531
545
 
@@ -539,20 +553,26 @@ async function listInbox(
539
553
  const adapter = strField(d, '$adapter', 'adapter');
540
554
  const endpointKey = strField(d, '$endpoint', 'endpointKey', 'endpoint');
541
555
  if (!adapter || !endpointKey) return { error: '$adapter and $endpoint required' };
542
- const limit = Math.min(numField(d, 30, '$limit', 'limit'), 100);
543
- const offset = Math.max(0, numField(d, 0, '$offset', 'offset'));
544
- const { rows, enabled } = await readInboxRows(ctx, table, {
556
+ const limit = boundedIntegerField(d, 30, 1, 100, '$limit', 'limit');
557
+ const offset = boundedIntegerField(
558
+ d, 0, 0, MAX_INBOX_OFFSET, '$offset', 'offset',
559
+ );
560
+ const unreadOnly = table === TABLE_NOTICE
561
+ && boolField(d, false, '$unread_only', 'unreadOnly', 'unread_only');
562
+ const { rows, enabled, offsetApplied } = await readInboxRows(ctx, table, {
545
563
  adapter,
546
564
  endpoint_id: endpointKey,
565
+ ...(unreadOnly ? { consumed: 0 } : {}),
547
566
  }, {
548
567
  // sort/limit 下推到 DB 侧(内存 sort/slice 保留,作为无下推能力模型的兜底)
549
568
  orderBy: { field: 'created_at', direction: 'DESC' },
550
- limit: offset + limit,
569
+ limit,
570
+ offset,
551
571
  });
552
572
  const sorted = rows
553
573
  .slice()
554
574
  .sort((a, b) => Number(b.created_at ?? 0) - Number(a.created_at ?? 0))
555
- .slice(offset, offset + limit)
575
+ .slice(offsetApplied ? 0 : offset, (offsetApplied ? 0 : offset) + limit)
556
576
  .map(mapRow);
557
577
  return { data: { [key]: sorted, inboxEnabled: enabled } };
558
578
  }
@@ -615,7 +635,7 @@ async function listInboxMessages(
615
635
  if (!adapter || !endpointKey || !channelId || !channelType) {
616
636
  return { error: '$adapter, $endpoint, $channel_id, $channel_type required' };
617
637
  }
618
- const limit = Math.min(numField(d, 50, '$limit', 'limit'), 100);
638
+ const limit = boundedIntegerField(d, 50, 1, 100, '$limit', 'limit');
619
639
  const beforeTs = optionalNum(d, '$before_ts', 'beforeTs', 'before_ts');
620
640
  const beforeId = optionalNum(d, '$before_id', 'beforeId', 'before_id');
621
641
  const parent = normalizeParent(d.$parent ?? d.parent);
@@ -685,16 +705,18 @@ function mapRequestRow(row: Record<string, unknown>): Record<string, unknown> {
685
705
  function mapNoticeRow(row: Record<string, unknown>): Record<string, unknown> {
686
706
  const actorId = row.actor_id ?? undefined;
687
707
  const actorName = row.actor_name ?? undefined;
688
- const sceneId = row.scene_id;
689
- const sceneType = row.scene_type ?? undefined;
708
+ const sceneId = String(row.scene_id ?? '');
709
+ const sceneType = row.scene_type == null ? undefined : String(row.scene_type);
690
710
  return {
691
711
  id: row.id,
692
712
  platform_notice_id: row.platform_notice_id,
693
713
  type: row.type,
714
+ noticeType: row.type,
694
715
  scene_type: sceneType,
695
716
  scene_id: sceneId,
696
717
  channel_id: sceneId,
697
718
  channel_type: sceneType,
719
+ channel: { id: sceneId, type: sceneType ?? '' },
698
720
  sub_type: row.sub_type ?? undefined,
699
721
  actor_id: actorId,
700
722
  actor_name: actorName,
@@ -706,6 +728,8 @@ function mapNoticeRow(row: Record<string, unknown>): Record<string, unknown> {
706
728
  payload: row.payload,
707
729
  created_at: row.created_at,
708
730
  timestamp: row.created_at,
731
+ consumed: row.consumed,
732
+ consumed_at: row.consumed_at ?? undefined,
709
733
  };
710
734
  }
711
735
 
@@ -965,6 +989,26 @@ function numField(d: Record<string, unknown>, fallback: number, ...keys: string[
965
989
  return fallback;
966
990
  }
967
991
 
992
+ function boundedIntegerField(
993
+ d: Record<string, unknown>,
994
+ fallback: number,
995
+ minimum: number,
996
+ maximum: number,
997
+ ...keys: string[]
998
+ ): number {
999
+ const value = numField(d, fallback, ...keys);
1000
+ if (!Number.isFinite(value)) return fallback;
1001
+ return Math.min(maximum, Math.max(minimum, Math.trunc(value)));
1002
+ }
1003
+
1004
+ function boolField(d: Record<string, unknown>, fallback: boolean, ...keys: string[]): boolean {
1005
+ for (const key of keys) {
1006
+ const value = d[key];
1007
+ if (typeof value === 'boolean') return value;
1008
+ }
1009
+ return fallback;
1010
+ }
1011
+
968
1012
  function optionalNum(d: Record<string, unknown>, ...keys: string[]): number | undefined {
969
1013
  for (const key of keys) {
970
1014
  if (d[key] == null) continue;
@@ -801,6 +801,8 @@ export function isDemoHttpAllowed(method: string, pathname: string, apiBase: str
801
801
  const normalizedBase = apiBase.replace(/\/$/u, '') || '/api';
802
802
  const m = method.toUpperCase();
803
803
  if (m === 'GET' && pathname === `${normalizedBase}/events`) return true;
804
+ if (m === 'GET' && pathname === `${normalizedBase}/events/history`) return true;
805
+ if (m === 'GET' && DEMO_INTROSPECTION_PATHS.has(pathname.slice(normalizedBase.length))) return true;
804
806
  if (m === 'POST' && pathname === `${normalizedBase}/console/request`) return true;
805
807
  // Read-only dashboard / plugin inspection routes (legacy host-api parity).
806
808
  if (m === 'GET' && pathname === `${normalizedBase}/system/status`) return true;
@@ -809,3 +811,13 @@ export function isDemoHttpAllowed(method: string, pathname: string, apiBase: str
809
811
  || pathname.startsWith(`${normalizedBase}/plugins/`))) return true;
810
812
  return false;
811
813
  }
814
+
815
+ const DEMO_INTROSPECTION_PATHS: ReadonlySet<string> = new Set([
816
+ '/introspection/commands',
817
+ '/introspection/middlewares',
818
+ '/introspection/components',
819
+ '/introspection/endpoints',
820
+ '/introspection/bindings',
821
+ '/introspection/tools',
822
+ '/introspection/mcp',
823
+ ]);