@zhin.js/client 2.1.10 → 2.1.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/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: { type: string; data?: unknown }): Promise<void> {
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 = Date.now();
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
- id: `${parsed.adapter}:${parsed.endpointKey}:${parsed.type}:${updatedAt}:${inboxEventSeq++}:${Math.random().toString(36).slice(2, 8)}`,
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 { WebSocketManager } from "./manager";
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 = vi.fn(async () => sseResponse(200));
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 init = fetchMock.mock.calls[0]![1] as RequestInit;
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 = vi.fn(async () => sseResponse(200));
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 init = fetchMock.mock.calls[0]![1] as RequestInit;
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 calls = 0;
112
- const fetchMock = vi.fn(async () => {
113
- calls += 1;
114
- if (calls === 1) return sseResponse(503, null);
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(calls).toBe(1);
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(calls).toBeGreaterThanOrEqual(2);
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 = vi.fn(async () => sseResponse(500, null));
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
- expect(fetchMock.mock.calls.length).toBeLessThanOrEqual(3);
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
- const res = await fetch(resolveApiUrl("/api/events"), {
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("\n\n");
162
+ const parts = buffer.split(/\r?\n\r?\n/u);
111
163
  buffer = parts.pop() ?? "";
112
164
  for (const part of parts) {
113
- const line = part.split("\n").find((l) => l.startsWith("data: "));
114
- if (!line) continue;
115
- const json = line.slice(6);
116
- try {
117
- const message = normalizeConsolePushMessage(
118
- JSON.parse(json) as WebSocketMessage,
119
- ) as WebSocketMessage;
120
- void applyConsoleEvent(message);
121
- this.handleBroadcast(message);
122
- this.callbacks.onMessage?.(message);
123
- } catch {
124
- /* skip */
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