@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.
- package/lib/console-events.d.ts +18 -4
- package/lib/console-events.js +121 -9
- package/lib/console-rpc-extended.js +45 -13
- package/lib/console-rpc.js +13 -0
- package/package.json +2 -2
- package/src/console-events.ts +170 -12
- package/src/console-rpc-extended.ts +58 -14
- package/src/console-rpc.ts +12 -0
package/lib/console-events.d.ts
CHANGED
|
@@ -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:
|
|
9
|
-
|
|
10
|
-
|
|
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
|
|
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;
|
package/lib/console-events.js
CHANGED
|
@@ -1,33 +1,145 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
13
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
340
|
-
|
|
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 =
|
|
356
|
-
const offset =
|
|
357
|
-
const
|
|
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
|
|
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 =
|
|
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
|
|
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)
|
package/lib/console-rpc.js
CHANGED
|
@@ -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.
|
|
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.
|
|
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"
|
package/src/console-events.ts
CHANGED
|
@@ -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
|
|
10
|
-
|
|
11
|
-
|
|
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
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
522
|
-
|
|
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 =
|
|
543
|
-
const offset =
|
|
544
|
-
|
|
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
|
|
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 =
|
|
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
|
|
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;
|
package/src/console-rpc.ts
CHANGED
|
@@ -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
|
+
]);
|