@zhin.js/client 2.1.10 → 2.1.11
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 +6 -0
- package/client/console-events.ts +37 -0
- package/client/index.ts +25 -0
- package/client/persistence/idb-store.test.ts +11 -0
- package/client/persistence/idb-store.ts +20 -4
- package/client/websocket/index.ts +6 -1
- package/client/websocket/manager.test.ts +145 -12
- package/client/websocket/manager.ts +189 -17
- package/client/websocket/types.ts +5 -0
- package/dist/console-events.d.ts +7 -0
- package/dist/console-events.d.ts.map +1 -0
- package/dist/console-events.js +26 -0
- package/dist/console-events.js.map +1 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/persistence/idb-store.d.ts +3 -0
- package/dist/persistence/idb-store.d.ts.map +1 -1
- package/dist/persistence/idb-store.js +13 -3
- package/dist/persistence/idb-store.js.map +1 -1
- package/dist/websocket/index.d.ts +1 -1
- package/dist/websocket/index.d.ts.map +1 -1
- package/dist/websocket/index.js +1 -1
- package/dist/websocket/index.js.map +1 -1
- package/dist/websocket/manager.d.ts +19 -0
- package/dist/websocket/manager.d.ts.map +1 -1
- package/dist/websocket/manager.js +176 -18
- package/dist/websocket/manager.js.map +1 -1
- package/dist/websocket/types.d.ts +4 -0
- package/dist/websocket/types.d.ts.map +1 -1
- package/dist/websocket/types.js.map +1 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -27,6 +27,10 @@ Peer:`react >= 18`(`createPluginRegisterHostApi` 需要 React 引用)。
|
|
|
27
27
|
| `normalizeConsolePushType` / `normalizeConsolePushMessage` | 在 SDK 边界兼容旧 `endpoint:*` 推送并输出规范事件与 payload |
|
|
28
28
|
| `parseConsoleInboxEvent` | 一次完成 Inbox 推送名称、身份别名和 message/request/notice 分类 |
|
|
29
29
|
| `ConsoleEndpointSummary` / `EndpointManagementCapability` | Host 与 Remote Console 共享的 Endpoint wire 类型 |
|
|
30
|
+
| `fetchConsoleEventHistory` | 按 `(runtimeId, eventId)` 拉取有界事件历史 |
|
|
31
|
+
| `WebSocketManager.onConsoleEvent` | 订阅带 `live/history` 投递来源的强类型事件 |
|
|
32
|
+
| `WebSocketManager.onConsoleEventRecoveryGap` | 观察不可续接游标并触发领域全量重同步 |
|
|
33
|
+
| `ConsoleInboxNoticesQuery` / `ConsoleInboxNoticesResult` | 以 `unreadOnly` 从持久 Inbox 重建未读通知 |
|
|
30
34
|
|
|
31
35
|
类型与 Entry 契约来自 `@zhin.js/contract`。
|
|
32
36
|
|
|
@@ -97,6 +101,8 @@ const res = await apiFetch("/api/console/request", {
|
|
|
97
101
|
|
|
98
102
|
辅助:`resolveApiUrl`、`resolveWebSocketUrl`(SSE `/api/events` 等)。
|
|
99
103
|
|
|
104
|
+
Client 在连接 `/api/events` 前先补拉 `/api/events/history`,再携带最新游标进入 SSE;Host 会原子重放 HTTP 与订阅之间产生的事件。游标只在 Inbox 持久化成功后推进,重复的 history/live 投递按 `(runtimeId, eventId)` 幂等写入 IndexedDB。`gap` 不会被吞掉,页面可订阅恢复缺口并改走领域 HTTP 全量投影。
|
|
105
|
+
|
|
100
106
|
## `createPluginRegisterHostApi`
|
|
101
107
|
|
|
102
108
|
将壳层已有的 React 与路由/工具注册函数适配为契约中的 `PluginRegisterHostApi`(含 `addPage` 别名 → `addRoute`):
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ConsoleEventHistoryPage,
|
|
3
|
+
ConsoleEventHistoryQuery,
|
|
4
|
+
} from '@zhin.js/console-protocol';
|
|
5
|
+
import { getStoredToken, resolveApiUrl } from './websocket/remote-settings.js';
|
|
6
|
+
|
|
7
|
+
export interface FetchConsoleEventHistoryOptions {
|
|
8
|
+
readonly signal?: AbortSignal;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Read a typed page from the Host's bounded Console event journal. */
|
|
12
|
+
export async function fetchConsoleEventHistory(
|
|
13
|
+
query: ConsoleEventHistoryQuery = {},
|
|
14
|
+
options: FetchConsoleEventHistoryOptions = {},
|
|
15
|
+
): Promise<ConsoleEventHistoryPage> {
|
|
16
|
+
const params = new URLSearchParams();
|
|
17
|
+
if (query.runtimeId) params.set('runtimeId', query.runtimeId);
|
|
18
|
+
if (query.after !== undefined) params.set('after', String(query.after));
|
|
19
|
+
if (query.limit !== undefined) params.set('limit', String(query.limit));
|
|
20
|
+
const token = getStoredToken();
|
|
21
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
22
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
23
|
+
const suffix = params.size ? `?${params}` : '';
|
|
24
|
+
const response = await fetch(resolveApiUrl(`/api/events/history${suffix}`), {
|
|
25
|
+
headers,
|
|
26
|
+
signal: options.signal,
|
|
27
|
+
});
|
|
28
|
+
const body = await response.json() as {
|
|
29
|
+
success?: boolean;
|
|
30
|
+
data?: ConsoleEventHistoryPage;
|
|
31
|
+
error?: string;
|
|
32
|
+
};
|
|
33
|
+
if (!response.ok || body.success !== true || !body.data) {
|
|
34
|
+
throw new Error(body.error ?? `Console event history failed: HTTP ${response.status}`);
|
|
35
|
+
}
|
|
36
|
+
return body.data;
|
|
37
|
+
}
|
package/client/index.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
export * from "./types";
|
|
3
3
|
export {
|
|
4
4
|
ENDPOINT_RPC,
|
|
5
|
+
CONSOLE_EVENT_RECOVERY_GAP_EVENT,
|
|
5
6
|
ENDPOINT_MANAGEMENT_CAPABILITIES,
|
|
6
7
|
INBOX_RPC,
|
|
7
8
|
SIDE_EVENT_PUSH,
|
|
@@ -11,11 +12,35 @@ export {
|
|
|
11
12
|
parseConsoleInboxEvent,
|
|
12
13
|
type ConsoleInboxEvent,
|
|
13
14
|
type ConsoleInboxEventKind,
|
|
15
|
+
type ConsoleInboxNoticeRow,
|
|
16
|
+
type ConsoleInboxNoticesQuery,
|
|
17
|
+
type ConsoleInboxNoticesResult,
|
|
14
18
|
type ConsoleEndpointPhase,
|
|
15
19
|
type ConsoleEndpointSummary,
|
|
20
|
+
type ConsoleEventActor,
|
|
21
|
+
type ConsoleEventChannel,
|
|
16
22
|
type EndpointManagementCapability,
|
|
23
|
+
parseConsoleSseFrame,
|
|
24
|
+
type ConsoleEventDelivery,
|
|
25
|
+
type ConsoleEventData,
|
|
26
|
+
type ConsoleEndpointEventData,
|
|
27
|
+
type ConsoleEventEnvelope,
|
|
28
|
+
type ConsoleEventHistoryPage,
|
|
29
|
+
type ConsoleEventHistoryQuery,
|
|
30
|
+
type ConsoleEventPayloadMap,
|
|
31
|
+
type ConsoleMessageEventData,
|
|
32
|
+
type ConsoleNoticeEventData,
|
|
33
|
+
type ConsoleRequestEventData,
|
|
34
|
+
type KnownConsoleEventType,
|
|
35
|
+
type KnownConsoleEventEnvelope,
|
|
36
|
+
type ParsedConsoleSseFrame,
|
|
17
37
|
} from "@zhin.js/console-protocol";
|
|
18
38
|
|
|
39
|
+
export {
|
|
40
|
+
fetchConsoleEventHistory,
|
|
41
|
+
type FetchConsoleEventHistoryOptions,
|
|
42
|
+
} from './console-events.js';
|
|
43
|
+
|
|
19
44
|
// Media URL resolution
|
|
20
45
|
export { resolveMediaSrc, pickMediaRawUrl, type MediaKind } from "./mediaSrc";
|
|
21
46
|
|
|
@@ -108,6 +108,17 @@ describe("applyConsoleEvent inbox record ids", () => {
|
|
|
108
108
|
expect(new Set(rows.map((r) => r.id)).size).toBe(2);
|
|
109
109
|
});
|
|
110
110
|
|
|
111
|
+
it('deduplicates the same resumable event across history and live delivery', async () => {
|
|
112
|
+
const event = { ...PUSH, runtimeId: 'runtime-a', eventId: 7, timestamp: 1700000000000 };
|
|
113
|
+
await applyConsoleEvent(event);
|
|
114
|
+
await applyConsoleEvent(event);
|
|
115
|
+
|
|
116
|
+
const rows = await idbListInbox('icqq', 'bot-1', 'message');
|
|
117
|
+
expect(rows).toHaveLength(1);
|
|
118
|
+
expect(rows[0]?.id).toBe('runtime-a:7');
|
|
119
|
+
expect(rows[0]?.updatedAt).toBe(1700000000000);
|
|
120
|
+
});
|
|
121
|
+
|
|
111
122
|
it("reads legacy records that only carry endpointKey (camelCase)", async () => {
|
|
112
123
|
// 旧版(DB v1 时代)记录只有 endpointKey 字段,升级后仍需能被列出
|
|
113
124
|
const { idbPutInbox } = await import("./idb-store.js");
|
|
@@ -66,13 +66,29 @@ export async function idbListInbox(
|
|
|
66
66
|
);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
export async function applyConsoleEvent(event: {
|
|
69
|
+
export async function applyConsoleEvent(event: {
|
|
70
|
+
type: string;
|
|
71
|
+
data?: unknown;
|
|
72
|
+
runtimeId?: string;
|
|
73
|
+
eventId?: number;
|
|
74
|
+
timestamp?: number;
|
|
75
|
+
}): Promise<void> {
|
|
70
76
|
const parsed = parseConsoleInboxEvent(event);
|
|
71
77
|
if (!parsed) return;
|
|
72
|
-
const updatedAt =
|
|
78
|
+
const updatedAt = typeof event.timestamp === 'number' && Number.isFinite(event.timestamp)
|
|
79
|
+
? event.timestamp
|
|
80
|
+
: Date.now();
|
|
81
|
+
const resumableId = typeof event.runtimeId === 'string'
|
|
82
|
+
&& event.runtimeId
|
|
83
|
+
&& Number.isSafeInteger(event.eventId)
|
|
84
|
+
&& (event.eventId ?? 0) > 0
|
|
85
|
+
? `${event.runtimeId}:${event.eventId}`
|
|
86
|
+
: null;
|
|
73
87
|
await idbPutInbox({
|
|
74
|
-
//
|
|
75
|
-
|
|
88
|
+
// Resumable Host events are idempotent across history/live redelivery.
|
|
89
|
+
// Legacy/unsequenced sources retain collision-safe append ids.
|
|
90
|
+
id: resumableId
|
|
91
|
+
?? `${parsed.adapter}:${parsed.endpointKey}:${parsed.type}:${updatedAt}:${inboxEventSeq++}:${Math.random().toString(36).slice(2, 8)}`,
|
|
76
92
|
adapter: parsed.adapter,
|
|
77
93
|
endpoint_id: parsed.endpointKey,
|
|
78
94
|
kind: parsed.kind,
|
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
export * from "./types";
|
|
2
|
-
export {
|
|
2
|
+
export {
|
|
3
|
+
WebSocketManager,
|
|
4
|
+
type ConsoleEventListener,
|
|
5
|
+
type ConsoleEventRecoveryGap,
|
|
6
|
+
type ConsoleEventRecoveryGapListener,
|
|
7
|
+
} from "./manager";
|
|
3
8
|
export { getWebSocketManager, destroyWebSocketManager, resetWebSocketManager } from "./instance";
|
|
4
9
|
export { useWebSocket, useConfig, useConfigYaml, useFiles, useEnvFiles, useDatabase } from "./hooks";
|
|
5
10
|
import { getWebSocketManager } from "./instance";
|
|
@@ -54,6 +54,32 @@ function sseResponse(status = 200, body?: ReadableStream<Uint8Array> | null) {
|
|
|
54
54
|
} as Response;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
function eventHistoryResponse(items: unknown[] = [], runtimeId = 'runtime-test', latestEventId?: number) {
|
|
58
|
+
const inferredLatest = (items.at(-1) as { eventId?: number } | undefined)?.eventId ?? 0;
|
|
59
|
+
return {
|
|
60
|
+
ok: true,
|
|
61
|
+
status: 200,
|
|
62
|
+
json: async () => ({
|
|
63
|
+
success: true,
|
|
64
|
+
data: {
|
|
65
|
+
runtimeId,
|
|
66
|
+
items,
|
|
67
|
+
oldestAvailableEventId: items.length ? 1 : null,
|
|
68
|
+
latestEventId: latestEventId ?? inferredLatest,
|
|
69
|
+
nextAfter: inferredLatest,
|
|
70
|
+
hasMore: false,
|
|
71
|
+
gap: false,
|
|
72
|
+
},
|
|
73
|
+
}),
|
|
74
|
+
} as Response;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function mockConsoleTransport(sse: () => Response | Promise<Response>) {
|
|
78
|
+
return vi.fn(async (input: RequestInfo | URL, _init?: RequestInit) => {
|
|
79
|
+
return String(input).includes('/api/events/history') ? eventHistoryResponse() : await sse();
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
57
83
|
describe("WebSocketManager REST/SSE transport", () => {
|
|
58
84
|
beforeEach(() => {
|
|
59
85
|
vi.useFakeTimers();
|
|
@@ -68,7 +94,7 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
68
94
|
|
|
69
95
|
it("connects SSE without a token (local Host with empty TokenRegistry)", async () => {
|
|
70
96
|
installBrowserGlobals();
|
|
71
|
-
const fetchMock =
|
|
97
|
+
const fetchMock = mockConsoleTransport(() => sseResponse(200));
|
|
72
98
|
vi.stubGlobal("fetch", fetchMock);
|
|
73
99
|
|
|
74
100
|
const manager = new WebSocketManager({ reconnectInterval: 100, maxReconnectAttempts: 3 });
|
|
@@ -81,7 +107,8 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
81
107
|
await Promise.resolve();
|
|
82
108
|
|
|
83
109
|
expect(fetchMock).toHaveBeenCalled();
|
|
84
|
-
const
|
|
110
|
+
const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
|
|
111
|
+
const init = sseCall[1] as RequestInit;
|
|
85
112
|
const headers = init.headers as Record<string, string>;
|
|
86
113
|
expect(headers.Accept).toBe("text/event-stream");
|
|
87
114
|
expect(headers.Authorization).toBeUndefined();
|
|
@@ -92,7 +119,7 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
92
119
|
|
|
93
120
|
it("sends Authorization when a token is stored", async () => {
|
|
94
121
|
installBrowserGlobals(mockStorage({ zhin_api_token: "secret" }));
|
|
95
|
-
const fetchMock =
|
|
122
|
+
const fetchMock = mockConsoleTransport(() => sseResponse(200));
|
|
96
123
|
vi.stubGlobal("fetch", fetchMock);
|
|
97
124
|
|
|
98
125
|
const manager = new WebSocketManager();
|
|
@@ -101,17 +128,18 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
101
128
|
await Promise.resolve();
|
|
102
129
|
await Promise.resolve();
|
|
103
130
|
|
|
104
|
-
const
|
|
131
|
+
const sseCall = fetchMock.mock.calls.find(([input]) => !String(input).includes('/history'))!;
|
|
132
|
+
const init = sseCall[1] as RequestInit;
|
|
105
133
|
expect((init.headers as Record<string, string>).Authorization).toBe("Bearer secret");
|
|
106
134
|
manager.disconnect();
|
|
107
135
|
});
|
|
108
136
|
|
|
109
137
|
it("reconnects after a transient SSE failure (no ERROR deadlock)", async () => {
|
|
110
138
|
installBrowserGlobals();
|
|
111
|
-
let
|
|
112
|
-
const fetchMock =
|
|
113
|
-
|
|
114
|
-
if (
|
|
139
|
+
let sseCalls = 0;
|
|
140
|
+
const fetchMock = mockConsoleTransport(() => {
|
|
141
|
+
sseCalls += 1;
|
|
142
|
+
if (sseCalls === 1) return sseResponse(503, null);
|
|
115
143
|
return sseResponse(200);
|
|
116
144
|
});
|
|
117
145
|
vi.stubGlobal("fetch", fetchMock);
|
|
@@ -127,14 +155,14 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
127
155
|
await Promise.resolve();
|
|
128
156
|
await Promise.resolve();
|
|
129
157
|
expect(manager.getState()).toBe(ConnectionState.RECONNECTING);
|
|
130
|
-
expect(
|
|
158
|
+
expect(sseCalls).toBe(1);
|
|
131
159
|
|
|
132
160
|
// scheduleReconnect delay = 100 * attempt(1) = 100ms
|
|
133
161
|
await vi.advanceTimersByTimeAsync(100);
|
|
134
162
|
await Promise.resolve();
|
|
135
163
|
await Promise.resolve();
|
|
136
164
|
|
|
137
|
-
expect(
|
|
165
|
+
expect(sseCalls).toBeGreaterThanOrEqual(2);
|
|
138
166
|
expect(manager.getState()).toBe(ConnectionState.CONNECTED);
|
|
139
167
|
manager.disconnect();
|
|
140
168
|
});
|
|
@@ -194,7 +222,7 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
194
222
|
|
|
195
223
|
it("stops reconnecting after maxReconnectAttempts", async () => {
|
|
196
224
|
installBrowserGlobals();
|
|
197
|
-
const fetchMock =
|
|
225
|
+
const fetchMock = mockConsoleTransport(() => sseResponse(500, null));
|
|
198
226
|
vi.stubGlobal("fetch", fetchMock);
|
|
199
227
|
|
|
200
228
|
const manager = new WebSocketManager({
|
|
@@ -210,7 +238,112 @@ describe("WebSocketManager REST/SSE transport", () => {
|
|
|
210
238
|
}
|
|
211
239
|
|
|
212
240
|
expect(manager.getState()).toBe(ConnectionState.ERROR);
|
|
213
|
-
|
|
241
|
+
const sseCalls = fetchMock.mock.calls.filter(([input]) => !String(input).includes('/history'));
|
|
242
|
+
expect(sseCalls.length).toBeLessThanOrEqual(3);
|
|
214
243
|
manager.disconnect();
|
|
215
244
|
});
|
|
245
|
+
|
|
246
|
+
it('recovers typed history before live SSE and preserves event metadata', async () => {
|
|
247
|
+
installBrowserGlobals();
|
|
248
|
+
const encoder = new TextEncoder();
|
|
249
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
250
|
+
start(controller) {
|
|
251
|
+
controller.enqueue(encoder.encode([
|
|
252
|
+
'id: 2',
|
|
253
|
+
'event: message.receive',
|
|
254
|
+
'runtime: runtime-a',
|
|
255
|
+
'timestamp: 200',
|
|
256
|
+
'data: {"adapter":"sandbox","endpointKey":"bot","content":"live"}',
|
|
257
|
+
'',
|
|
258
|
+
'',
|
|
259
|
+
].join('\n')));
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
const historyEvent = {
|
|
263
|
+
runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
|
|
264
|
+
data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
|
|
265
|
+
};
|
|
266
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => (
|
|
267
|
+
String(input).includes('/history')
|
|
268
|
+
? eventHistoryResponse([historyEvent], 'runtime-a')
|
|
269
|
+
: sseResponse(200, stream)
|
|
270
|
+
));
|
|
271
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
272
|
+
const received: Array<{ eventId: number; delivery?: string }> = [];
|
|
273
|
+
const manager = new WebSocketManager();
|
|
274
|
+
manager.onConsoleEvent('message.receive', (event) => received.push({ eventId: event.eventId, delivery: event.delivery }));
|
|
275
|
+
manager.connect();
|
|
276
|
+
await vi.advanceTimersByTimeAsync(0);
|
|
277
|
+
await Promise.resolve();
|
|
278
|
+
await Promise.resolve();
|
|
279
|
+
|
|
280
|
+
expect(received).toEqual([
|
|
281
|
+
{ eventId: 1, delivery: 'history' },
|
|
282
|
+
{ eventId: 2, delivery: 'live' },
|
|
283
|
+
]);
|
|
284
|
+
expect(mockApplyConsoleEvent).toHaveBeenCalledTimes(2);
|
|
285
|
+
manager.disconnect();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it('keeps an in-memory cursor when localStorage is unavailable', async () => {
|
|
289
|
+
const storage = mockStorage();
|
|
290
|
+
storage.setItem = () => { throw new Error('storage disabled'); };
|
|
291
|
+
installBrowserGlobals(storage);
|
|
292
|
+
const historyEvent = {
|
|
293
|
+
runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
|
|
294
|
+
data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
|
|
295
|
+
};
|
|
296
|
+
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
|
297
|
+
const after = new URL(String(input)).searchParams.get('after');
|
|
298
|
+
return after === '1'
|
|
299
|
+
? eventHistoryResponse([], 'runtime-a', 1)
|
|
300
|
+
: eventHistoryResponse([historyEvent], 'runtime-a', 1);
|
|
301
|
+
});
|
|
302
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
303
|
+
const received: number[] = [];
|
|
304
|
+
const manager = new WebSocketManager();
|
|
305
|
+
manager.onConsoleEvent('message.receive', (event) => received.push(event.eventId));
|
|
306
|
+
const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
|
|
307
|
+
await recover.recoverEventHistory(new AbortController().signal);
|
|
308
|
+
await recover.recoverEventHistory(new AbortController().signal);
|
|
309
|
+
|
|
310
|
+
expect(received).toEqual([1]);
|
|
311
|
+
expect(fetchMock.mock.calls[1]?.[0]).toContain('after=1');
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it('exposes a typed recovery gap instead of silently treating it as complete', async () => {
|
|
315
|
+
installBrowserGlobals();
|
|
316
|
+
const response = eventHistoryResponse([], 'runtime-new', 9);
|
|
317
|
+
const originalJson = response.json;
|
|
318
|
+
response.json = async () => {
|
|
319
|
+
const body = await originalJson() as { data: Record<string, unknown> };
|
|
320
|
+
return { ...body, data: { ...body.data, gap: true, oldestAvailableEventId: 8 } };
|
|
321
|
+
};
|
|
322
|
+
vi.stubGlobal('fetch', vi.fn(async () => response));
|
|
323
|
+
const manager = new WebSocketManager();
|
|
324
|
+
const gaps: unknown[] = [];
|
|
325
|
+
manager.onConsoleEventRecoveryGap((gap) => gaps.push(gap));
|
|
326
|
+
const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
|
|
327
|
+
await recover.recoverEventHistory(new AbortController().signal);
|
|
328
|
+
|
|
329
|
+
expect(gaps).toEqual([expect.objectContaining({
|
|
330
|
+
page: expect.objectContaining({ runtimeId: 'runtime-new', gap: true }),
|
|
331
|
+
})]);
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it('does not persist a cursor past an inbox event that failed durable storage', async () => {
|
|
335
|
+
const stored: Record<string, string> = {};
|
|
336
|
+
installBrowserGlobals(mockStorage(stored));
|
|
337
|
+
mockApplyConsoleEvent.mockRejectedValueOnce(new Error('IndexedDB unavailable'));
|
|
338
|
+
const historyEvent = {
|
|
339
|
+
runtimeId: 'runtime-a', eventId: 1, type: 'message.receive', timestamp: 100,
|
|
340
|
+
data: { adapter: 'sandbox', endpointKey: 'bot', content: 'history' },
|
|
341
|
+
};
|
|
342
|
+
vi.stubGlobal('fetch', vi.fn(async () => eventHistoryResponse([historyEvent], 'runtime-a', 1)));
|
|
343
|
+
const manager = new WebSocketManager();
|
|
344
|
+
const recover = manager as unknown as { recoverEventHistory(signal: AbortSignal): Promise<void> };
|
|
345
|
+
await recover.recoverEventHistory(new AbortController().signal);
|
|
346
|
+
|
|
347
|
+
expect(Object.keys(stored).filter((key) => key.includes('event-cursor'))).toEqual([]);
|
|
348
|
+
});
|
|
216
349
|
});
|
|
@@ -2,11 +2,32 @@ import { ConnectionState, WebSocketError, ConnectionError, MessageError, Request
|
|
|
2
2
|
|
|
3
3
|
import { getApiBase, getStoredToken, resolveApiUrl } from "./remote-settings.js";
|
|
4
4
|
import { applyConsoleEvent } from "../persistence/idb-store.js";
|
|
5
|
+
import { fetchConsoleEventHistory } from '../console-events.js';
|
|
5
6
|
import {
|
|
7
|
+
CONSOLE_EVENT_RECOVERY_GAP_EVENT,
|
|
6
8
|
SIDE_EVENT_PUSH,
|
|
7
9
|
normalizeConsolePushMessage,
|
|
10
|
+
parseConsoleSseFrame,
|
|
11
|
+
type ConsoleEventData,
|
|
12
|
+
type ConsoleEventEnvelope,
|
|
13
|
+
type ConsoleEventHistoryPage,
|
|
14
|
+
type ConsoleEventHistoryQuery,
|
|
8
15
|
} from "@zhin.js/console-protocol";
|
|
9
16
|
|
|
17
|
+
interface ConsoleEventCursor {
|
|
18
|
+
readonly runtimeId: string;
|
|
19
|
+
readonly eventId: number;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export type ConsoleEventListener<Type extends string> = (
|
|
23
|
+
event: ConsoleEventEnvelope<Type, ConsoleEventData<Type>>,
|
|
24
|
+
) => void;
|
|
25
|
+
export interface ConsoleEventRecoveryGap {
|
|
26
|
+
readonly query: ConsoleEventHistoryQuery;
|
|
27
|
+
readonly page: ConsoleEventHistoryPage;
|
|
28
|
+
}
|
|
29
|
+
export type ConsoleEventRecoveryGapListener = (gap: ConsoleEventRecoveryGap) => void;
|
|
30
|
+
|
|
10
31
|
export class WebSocketManager {
|
|
11
32
|
private ws: WebSocket | null = null;
|
|
12
33
|
private config: Required<WebSocketConfig>;
|
|
@@ -20,7 +41,12 @@ export class WebSocketManager {
|
|
|
20
41
|
{ resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
21
42
|
>();
|
|
22
43
|
private connectedListeners = new Set<(connected: boolean) => void>();
|
|
44
|
+
private eventListeners = new Map<string, Set<ConsoleEventListener<string>>>();
|
|
45
|
+
private recoveryGapListeners = new Set<ConsoleEventRecoveryGapListener>();
|
|
23
46
|
private sseAbort: AbortController | null = null;
|
|
47
|
+
private eventRuntimeId = '';
|
|
48
|
+
private lastEventId = 0;
|
|
49
|
+
private durableCursorBlocked = false;
|
|
24
50
|
private useRestTransport = true;
|
|
25
51
|
|
|
26
52
|
constructor(config: WebSocketConfig = {}, callbacks: WebSocketCallbacks = {}) {
|
|
@@ -38,6 +64,27 @@ export class WebSocketManager {
|
|
|
38
64
|
return () => this.connectedListeners.delete(listener);
|
|
39
65
|
}
|
|
40
66
|
|
|
67
|
+
/** Subscribe by event name; known names infer their exact payload type. */
|
|
68
|
+
onConsoleEvent<Type extends string>(type: Type, listener: ConsoleEventListener<Type>): () => void {
|
|
69
|
+
let listeners = this.eventListeners.get(type);
|
|
70
|
+
if (!listeners) {
|
|
71
|
+
listeners = new Set();
|
|
72
|
+
this.eventListeners.set(type, listeners);
|
|
73
|
+
}
|
|
74
|
+
const stored = listener as ConsoleEventListener<string>;
|
|
75
|
+
listeners.add(stored);
|
|
76
|
+
return () => {
|
|
77
|
+
listeners?.delete(stored);
|
|
78
|
+
if (listeners?.size === 0) this.eventListeners.delete(type);
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Observe a non-resumable cursor so domain views can perform a full resync. */
|
|
83
|
+
onConsoleEventRecoveryGap(listener: ConsoleEventRecoveryGapListener): () => void {
|
|
84
|
+
this.recoveryGapListeners.add(listener);
|
|
85
|
+
return () => this.recoveryGapListeners.delete(listener);
|
|
86
|
+
}
|
|
87
|
+
|
|
41
88
|
private notifyConnection(connected: boolean) {
|
|
42
89
|
for (const l of this.connectedListeners) l(connected);
|
|
43
90
|
}
|
|
@@ -73,7 +120,12 @@ export class WebSocketManager {
|
|
|
73
120
|
Accept: "text/event-stream",
|
|
74
121
|
};
|
|
75
122
|
if (token) headers.Authorization = `Bearer ${token}`;
|
|
76
|
-
|
|
123
|
+
await this.recoverEventHistory(this.sseAbort.signal);
|
|
124
|
+
const params = new URLSearchParams();
|
|
125
|
+
if (this.eventRuntimeId) params.set('runtimeId', this.eventRuntimeId);
|
|
126
|
+
if (this.lastEventId > 0) params.set('after', String(this.lastEventId));
|
|
127
|
+
const suffix = params.size ? `?${params}` : '';
|
|
128
|
+
const res = await fetch(resolveApiUrl(`/api/events${suffix}`), {
|
|
77
129
|
headers,
|
|
78
130
|
signal: this.sseAbort.signal,
|
|
79
131
|
});
|
|
@@ -107,22 +159,21 @@ export class WebSocketManager {
|
|
|
107
159
|
const { done, value } = await reader.read();
|
|
108
160
|
if (done) break;
|
|
109
161
|
buffer += decoder.decode(value, { stream: true });
|
|
110
|
-
const parts = buffer.split(
|
|
162
|
+
const parts = buffer.split(/\r?\n\r?\n/u);
|
|
111
163
|
buffer = parts.pop() ?? "";
|
|
112
164
|
for (const part of parts) {
|
|
113
|
-
const
|
|
114
|
-
if (!
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
}
|
|
165
|
+
const parsed = parseConsoleSseFrame(part);
|
|
166
|
+
if (!parsed) continue;
|
|
167
|
+
const runtimeId = parsed.runtimeId ?? this.eventRuntimeId;
|
|
168
|
+
const event: ConsoleEventEnvelope = Object.freeze({
|
|
169
|
+
runtimeId,
|
|
170
|
+
eventId: parsed.eventId ?? 0,
|
|
171
|
+
type: parsed.type,
|
|
172
|
+
data: parsed.data,
|
|
173
|
+
timestamp: parsed.timestamp ?? Date.now(),
|
|
174
|
+
delivery: 'live',
|
|
175
|
+
});
|
|
176
|
+
await this.deliverConsoleEvent(event);
|
|
126
177
|
}
|
|
127
178
|
}
|
|
128
179
|
} catch {
|
|
@@ -136,6 +187,99 @@ export class WebSocketManager {
|
|
|
136
187
|
}
|
|
137
188
|
}
|
|
138
189
|
|
|
190
|
+
private async recoverEventHistory(signal: AbortSignal): Promise<void> {
|
|
191
|
+
const stored = this.eventRuntimeId
|
|
192
|
+
? { runtimeId: this.eventRuntimeId, eventId: this.lastEventId }
|
|
193
|
+
: readEventCursor();
|
|
194
|
+
let runtimeId = stored?.runtimeId;
|
|
195
|
+
let after = stored?.eventId ?? 0;
|
|
196
|
+
if (runtimeId) {
|
|
197
|
+
this.eventRuntimeId = runtimeId;
|
|
198
|
+
this.lastEventId = after;
|
|
199
|
+
}
|
|
200
|
+
for (;;) {
|
|
201
|
+
const query = { runtimeId, after, limit: 500 } satisfies ConsoleEventHistoryQuery;
|
|
202
|
+
const page = await fetchConsoleEventHistory(query, { signal });
|
|
203
|
+
if (page.runtimeId !== runtimeId) {
|
|
204
|
+
runtimeId = page.runtimeId;
|
|
205
|
+
after = 0;
|
|
206
|
+
this.lastEventId = 0;
|
|
207
|
+
this.durableCursorBlocked = false;
|
|
208
|
+
}
|
|
209
|
+
this.eventRuntimeId = page.runtimeId;
|
|
210
|
+
if (page.gap) this.notifyRecoveryGap({ query, page });
|
|
211
|
+
for (const item of page.items) {
|
|
212
|
+
await this.deliverConsoleEvent(Object.freeze({ ...item, delivery: 'history' }));
|
|
213
|
+
after = item.eventId;
|
|
214
|
+
}
|
|
215
|
+
if (!page.hasMore) {
|
|
216
|
+
this.lastEventId = Math.max(after, page.latestEventId);
|
|
217
|
+
if (!this.durableCursorBlocked) {
|
|
218
|
+
writeEventCursor({ runtimeId: page.runtimeId, eventId: this.lastEventId });
|
|
219
|
+
}
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
after = page.nextAfter;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
private async deliverConsoleEvent(event: ConsoleEventEnvelope): Promise<void> {
|
|
227
|
+
if (event.runtimeId && event.runtimeId !== this.eventRuntimeId) {
|
|
228
|
+
this.eventRuntimeId = event.runtimeId;
|
|
229
|
+
this.lastEventId = 0;
|
|
230
|
+
this.durableCursorBlocked = false;
|
|
231
|
+
}
|
|
232
|
+
if (event.eventId > 0
|
|
233
|
+
&& event.runtimeId === this.eventRuntimeId
|
|
234
|
+
&& event.eventId <= this.lastEventId) return;
|
|
235
|
+
const message = normalizeConsolePushMessage({
|
|
236
|
+
type: event.type,
|
|
237
|
+
data: event.data,
|
|
238
|
+
runtimeId: event.runtimeId,
|
|
239
|
+
eventId: event.eventId || undefined,
|
|
240
|
+
timestamp: event.timestamp,
|
|
241
|
+
delivery: event.delivery,
|
|
242
|
+
}) as WebSocketMessage;
|
|
243
|
+
try {
|
|
244
|
+
await applyConsoleEvent(message);
|
|
245
|
+
} catch (error) {
|
|
246
|
+
this.durableCursorBlocked = true;
|
|
247
|
+
console.error('[Console transport] Failed to persist event:', error);
|
|
248
|
+
}
|
|
249
|
+
this.handleBroadcast(message);
|
|
250
|
+
try {
|
|
251
|
+
this.callbacks.onMessage?.(message);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
console.error('[Console transport] onMessage listener failed:', error);
|
|
254
|
+
}
|
|
255
|
+
for (const listener of this.eventListeners.get(event.type) ?? []) {
|
|
256
|
+
try {
|
|
257
|
+
listener(event as ConsoleEventEnvelope<string, unknown>);
|
|
258
|
+
} catch (error) {
|
|
259
|
+
console.error('[Console transport] event listener failed:', error);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
if (event.eventId > 0 && event.runtimeId) {
|
|
263
|
+
this.lastEventId = Math.max(this.lastEventId, event.eventId);
|
|
264
|
+
if (!this.durableCursorBlocked) {
|
|
265
|
+
writeEventCursor({ runtimeId: event.runtimeId, eventId: this.lastEventId });
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
private notifyRecoveryGap(gap: ConsoleEventRecoveryGap): void {
|
|
271
|
+
if (typeof window !== 'undefined') {
|
|
272
|
+
window.dispatchEvent(new CustomEvent(CONSOLE_EVENT_RECOVERY_GAP_EVENT, { detail: gap }));
|
|
273
|
+
}
|
|
274
|
+
for (const listener of this.recoveryGapListeners) {
|
|
275
|
+
try {
|
|
276
|
+
listener(gap);
|
|
277
|
+
} catch (error) {
|
|
278
|
+
console.error('[Console transport] recovery gap listener failed:', error);
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
|
|
139
283
|
disconnect(): void {
|
|
140
284
|
this.clearReconnectTimer();
|
|
141
285
|
this.clearPendingRequests();
|
|
@@ -362,12 +506,12 @@ export class WebSocketManager {
|
|
|
362
506
|
}
|
|
363
507
|
return;
|
|
364
508
|
}
|
|
365
|
-
if (t === "hmr:reload") {
|
|
509
|
+
if (t === "hmr:reload" && message.delivery !== 'history') {
|
|
366
510
|
console.info(`[HMR] File changed, reloading...`);
|
|
367
511
|
window.location.reload();
|
|
368
512
|
return;
|
|
369
513
|
}
|
|
370
|
-
if (t === "system:restarting") {
|
|
514
|
+
if (t === "system:restarting" && message.delivery !== 'history') {
|
|
371
515
|
console.info("[System] Server restarting, will reload shortly...");
|
|
372
516
|
setTimeout(() => window.location.reload(), 3000);
|
|
373
517
|
return;
|
|
@@ -443,3 +587,31 @@ export class WebSocketManager {
|
|
|
443
587
|
this.pendingRequests.clear();
|
|
444
588
|
}
|
|
445
589
|
}
|
|
590
|
+
|
|
591
|
+
function eventCursorStorageKey(): string {
|
|
592
|
+
return `zhin.console.event-cursor:${getApiBase()}`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function readEventCursor(): ConsoleEventCursor | null {
|
|
596
|
+
if (typeof localStorage === 'undefined') return null;
|
|
597
|
+
try {
|
|
598
|
+
const parsed = JSON.parse(localStorage.getItem(eventCursorStorageKey()) ?? 'null') as Partial<ConsoleEventCursor> | null;
|
|
599
|
+
return parsed
|
|
600
|
+
&& typeof parsed.runtimeId === 'string'
|
|
601
|
+
&& Number.isSafeInteger(parsed.eventId)
|
|
602
|
+
&& (parsed.eventId ?? -1) >= 0
|
|
603
|
+
? { runtimeId: parsed.runtimeId, eventId: parsed.eventId as number }
|
|
604
|
+
: null;
|
|
605
|
+
} catch {
|
|
606
|
+
return null;
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function writeEventCursor(cursor: ConsoleEventCursor): void {
|
|
611
|
+
if (typeof localStorage === 'undefined') return;
|
|
612
|
+
try {
|
|
613
|
+
localStorage.setItem(eventCursorStorageKey(), JSON.stringify(cursor));
|
|
614
|
+
} catch {
|
|
615
|
+
// HTTP history remains available when browser storage is unavailable.
|
|
616
|
+
}
|
|
617
|
+
}
|
|
@@ -1,6 +1,11 @@
|
|
|
1
|
+
import type { ConsoleEventDelivery } from '@zhin.js/console-protocol';
|
|
2
|
+
|
|
1
3
|
export interface BaseMessage {
|
|
2
4
|
type: string;
|
|
3
5
|
timestamp?: number;
|
|
6
|
+
runtimeId?: string;
|
|
7
|
+
eventId?: number;
|
|
8
|
+
delivery?: ConsoleEventDelivery;
|
|
4
9
|
requestId?: number;
|
|
5
10
|
error?: string;
|
|
6
11
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { ConsoleEventHistoryPage, ConsoleEventHistoryQuery } from '@zhin.js/console-protocol';
|
|
2
|
+
export interface FetchConsoleEventHistoryOptions {
|
|
3
|
+
readonly signal?: AbortSignal;
|
|
4
|
+
}
|
|
5
|
+
/** Read a typed page from the Host's bounded Console event journal. */
|
|
6
|
+
export declare function fetchConsoleEventHistory(query?: ConsoleEventHistoryQuery, options?: FetchConsoleEventHistoryOptions): Promise<ConsoleEventHistoryPage>;
|
|
7
|
+
//# sourceMappingURL=console-events.d.ts.map
|