@zhin.js/client 1.1.4 → 1.1.5
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 +89 -433
- package/client/agent-stream.ts +121 -0
- package/client/bootstrap/loadConsoleEntries.test.ts +28 -2
- package/client/bootstrap/loadConsoleEntries.ts +40 -8
- package/client/console-events.ts +37 -0
- package/client/console-utils/remoteApi.test.ts +91 -0
- package/client/console-utils/remoteApi.ts +47 -2
- package/client/index.ts +75 -19
- package/client/mediaSrc.test.ts +49 -0
- package/client/mediaSrc.ts +84 -11
- package/client/message-content/MarkdownContent.tsx +128 -0
- package/client/message-content/markdown.test.ts +48 -0
- package/client/message-content/markdown.ts +247 -0
- package/client/persistence/idb-store.test.ts +139 -0
- package/client/persistence/idb-store.ts +43 -21
- package/client/runtime/index.ts +1 -1
- package/client/segments.test.ts +16 -0
- package/client/segments.ts +39 -0
- package/client/types.ts +5 -5
- package/client/websocket/hooks.test.ts +21 -0
- package/client/websocket/hooks.ts +56 -27
- package/client/websocket/index.ts +6 -1
- package/client/websocket/manager.test.ts +349 -0
- package/client/websocket/manager.ts +245 -54
- package/client/websocket/remote-settings.ts +25 -3
- package/client/websocket/types.ts +5 -0
- package/client/workroom/WorkroomRunsPage.tsx +355 -0
- package/client/workroom/registerWorkroomConsole.test.ts +26 -0
- package/client/workroom/registerWorkroomConsole.ts +17 -0
- package/dist/agent-stream.d.ts +17 -0
- package/dist/agent-stream.d.ts.map +1 -0
- package/dist/agent-stream.js +73 -0
- package/dist/agent-stream.js.map +1 -0
- package/dist/bootstrap/loadConsoleEntries.d.ts +10 -1
- package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
- package/dist/bootstrap/loadConsoleEntries.js +37 -2
- package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
- 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/console-utils/remoteApi.d.ts +2 -0
- package/dist/console-utils/remoteApi.d.ts.map +1 -1
- package/dist/console-utils/remoteApi.js +46 -2
- package/dist/console-utils/remoteApi.js.map +1 -1
- package/dist/index.d.ts +9 -3
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +12 -3
- package/dist/index.js.map +1 -1
- package/dist/mediaSrc.d.ts +1 -1
- package/dist/mediaSrc.d.ts.map +1 -1
- package/dist/mediaSrc.js +91 -11
- package/dist/mediaSrc.js.map +1 -1
- package/dist/message-content/MarkdownContent.d.ts +11 -0
- package/dist/message-content/MarkdownContent.d.ts.map +1 -0
- package/dist/message-content/MarkdownContent.js +68 -0
- package/dist/message-content/MarkdownContent.js.map +1 -0
- package/dist/message-content/markdown.d.ts +47 -0
- package/dist/message-content/markdown.d.ts.map +1 -0
- package/dist/message-content/markdown.js +219 -0
- package/dist/message-content/markdown.js.map +1 -0
- package/dist/persistence/idb-store.d.ts +5 -2
- package/dist/persistence/idb-store.d.ts.map +1 -1
- package/dist/persistence/idb-store.js +31 -20
- package/dist/persistence/idb-store.js.map +1 -1
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.d.ts.map +1 -1
- package/dist/segments.d.ts +9 -0
- package/dist/segments.d.ts.map +1 -0
- package/dist/segments.js +35 -0
- package/dist/segments.js.map +1 -0
- package/dist/types.d.ts +4 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/websocket/hooks.d.ts +9 -0
- package/dist/websocket/hooks.d.ts.map +1 -1
- package/dist/websocket/hooks.js +52 -31
- package/dist/websocket/hooks.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 +20 -2
- package/dist/websocket/manager.d.ts.map +1 -1
- package/dist/websocket/manager.js +222 -44
- package/dist/websocket/manager.js.map +1 -1
- package/dist/websocket/remote-settings.d.ts.map +1 -1
- package/dist/websocket/remote-settings.js +27 -3
- package/dist/websocket/remote-settings.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/dist/workroom/WorkroomRunsPage.d.ts +2 -0
- package/dist/workroom/WorkroomRunsPage.d.ts.map +1 -0
- package/dist/workroom/WorkroomRunsPage.js +111 -0
- package/dist/workroom/WorkroomRunsPage.js.map +1 -0
- package/dist/workroom/registerWorkroomConsole.d.ts +4 -0
- package/dist/workroom/registerWorkroomConsole.d.ts.map +1 -0
- package/dist/workroom/registerWorkroomConsole.js +16 -0
- package/dist/workroom/registerWorkroomConsole.js.map +1 -0
- package/package.json +18 -8
- package/dist/bootstrap/loadConsoleEntries.test.d.ts +0 -2
- package/dist/bootstrap/loadConsoleEntries.test.d.ts.map +0 -1
- package/dist/bootstrap/loadConsoleEntries.test.js +0 -27
- package/dist/bootstrap/loadConsoleEntries.test.js.map +0 -1
|
@@ -1,17 +1,32 @@
|
|
|
1
|
-
import type
|
|
2
|
-
|
|
3
|
-
WebSocketConfig,
|
|
4
|
-
WebSocketCallbacks,
|
|
5
|
-
} from "./types";
|
|
6
|
-
import {
|
|
7
|
-
ConnectionState,
|
|
8
|
-
WebSocketError,
|
|
9
|
-
ConnectionError,
|
|
10
|
-
MessageError,
|
|
11
|
-
RequestTimeoutError,
|
|
12
|
-
} from "./types";
|
|
1
|
+
import { ConnectionState, WebSocketError, ConnectionError, MessageError, RequestTimeoutError, type WebSocketMessage, type WebSocketConfig, type WebSocketCallbacks } from './types';
|
|
2
|
+
|
|
13
3
|
import { getApiBase, getStoredToken, resolveApiUrl } from "./remote-settings.js";
|
|
14
4
|
import { applyConsoleEvent } from "../persistence/idb-store.js";
|
|
5
|
+
import { fetchConsoleEventHistory } from '../console-events.js';
|
|
6
|
+
import {
|
|
7
|
+
CONSOLE_EVENT_RECOVERY_GAP_EVENT,
|
|
8
|
+
SIDE_EVENT_PUSH,
|
|
9
|
+
normalizeConsolePushMessage,
|
|
10
|
+
parseConsoleSseFrame,
|
|
11
|
+
type ConsoleEventData,
|
|
12
|
+
type ConsoleEventEnvelope,
|
|
13
|
+
type ConsoleEventHistoryPage,
|
|
14
|
+
type ConsoleEventHistoryQuery,
|
|
15
|
+
} from "@zhin.js/console-protocol";
|
|
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;
|
|
15
30
|
|
|
16
31
|
export class WebSocketManager {
|
|
17
32
|
private ws: WebSocket | null = null;
|
|
@@ -26,7 +41,12 @@ export class WebSocketManager {
|
|
|
26
41
|
{ resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
|
|
27
42
|
>();
|
|
28
43
|
private connectedListeners = new Set<(connected: boolean) => void>();
|
|
44
|
+
private eventListeners = new Map<string, Set<ConsoleEventListener<string>>>();
|
|
45
|
+
private recoveryGapListeners = new Set<ConsoleEventRecoveryGapListener>();
|
|
29
46
|
private sseAbort: AbortController | null = null;
|
|
47
|
+
private eventRuntimeId = '';
|
|
48
|
+
private lastEventId = 0;
|
|
49
|
+
private durableCursorBlocked = false;
|
|
30
50
|
private useRestTransport = true;
|
|
31
51
|
|
|
32
52
|
constructor(config: WebSocketConfig = {}, callbacks: WebSocketCallbacks = {}) {
|
|
@@ -44,6 +64,27 @@ export class WebSocketManager {
|
|
|
44
64
|
return () => this.connectedListeners.delete(listener);
|
|
45
65
|
}
|
|
46
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
|
+
|
|
47
88
|
private notifyConnection(connected: boolean) {
|
|
48
89
|
for (const l of this.connectedListeners) l(connected);
|
|
49
90
|
}
|
|
@@ -60,25 +101,32 @@ export class WebSocketManager {
|
|
|
60
101
|
this.attachEventHandlers();
|
|
61
102
|
} catch (error) {
|
|
62
103
|
this.handleConnectionError(new ConnectionError("Failed to create WebSocket", error as Error));
|
|
104
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
105
|
+
this.notifyConnection(false);
|
|
106
|
+
this.scheduleReconnect();
|
|
63
107
|
}
|
|
64
108
|
}
|
|
65
109
|
|
|
66
110
|
private async connectRestSse(): Promise<void> {
|
|
111
|
+
// Host without http.token allows unauthenticated SSE (authenticateHttp
|
|
112
|
+
// returns full when TokenRegistry is empty). Still open the stream so
|
|
113
|
+
// local dev Console receives hmr:reload / message.receive broadcasts.
|
|
67
114
|
const token = getStoredToken();
|
|
68
|
-
if (!token) {
|
|
69
|
-
this.setState(ConnectionState.DISCONNECTED);
|
|
70
|
-
this.notifyConnection(false);
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
115
|
this.setState(ConnectionState.CONNECTING);
|
|
74
116
|
this.sseAbort?.abort();
|
|
75
117
|
this.sseAbort = new AbortController();
|
|
76
118
|
try {
|
|
77
|
-
const
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
119
|
+
const headers: Record<string, string> = {
|
|
120
|
+
Accept: "text/event-stream",
|
|
121
|
+
};
|
|
122
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
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}`), {
|
|
129
|
+
headers,
|
|
82
130
|
signal: this.sseAbort.signal,
|
|
83
131
|
});
|
|
84
132
|
if (!res.ok || !res.body) {
|
|
@@ -91,9 +139,13 @@ export class WebSocketManager {
|
|
|
91
139
|
void this.pumpSse(res.body);
|
|
92
140
|
} catch (error) {
|
|
93
141
|
if ((error as Error).name === "AbortError") return;
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
142
|
+
// Must set RECONNECTING (not ERROR) before scheduleReconnect — the timer
|
|
143
|
+
// only fires when state === RECONNECTING; handleConnectionError(ERROR)
|
|
144
|
+
// previously deadlocked reconnect after any transient SSE failure.
|
|
145
|
+
this.callbacks.onError?.(error as unknown as Event);
|
|
146
|
+
console.error("[Console transport] SSE connect error:", error);
|
|
147
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
148
|
+
this.notifyConnection(false);
|
|
97
149
|
this.scheduleReconnect();
|
|
98
150
|
}
|
|
99
151
|
}
|
|
@@ -107,20 +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
|
-
}
|
|
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);
|
|
124
177
|
}
|
|
125
178
|
}
|
|
126
179
|
} catch {
|
|
@@ -134,6 +187,99 @@ export class WebSocketManager {
|
|
|
134
187
|
}
|
|
135
188
|
}
|
|
136
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
|
+
|
|
137
283
|
disconnect(): void {
|
|
138
284
|
this.clearReconnectTimer();
|
|
139
285
|
this.clearPendingRequests();
|
|
@@ -184,24 +330,23 @@ export class WebSocketManager {
|
|
|
184
330
|
}
|
|
185
331
|
|
|
186
332
|
private async sendRestRequest<T>(message: unknown): Promise<T> {
|
|
333
|
+
// Host may run without http.token (local smoke). Only require a token when
|
|
334
|
+
// one is configured client-side; otherwise POST without Authorization and
|
|
335
|
+
// let the Host accept (TokenRegistry empty → full scope).
|
|
187
336
|
const token = getStoredToken();
|
|
188
|
-
if (!token) throw new WebSocketError("Not authenticated", "NOT_CONNECTED");
|
|
189
337
|
const requestId = ++this.requestId;
|
|
190
338
|
const body = { ...(message as Record<string, unknown>), requestId };
|
|
191
|
-
const timer = setTimeout(() => {
|
|
192
|
-
/* handled below */
|
|
193
|
-
}, this.config.requestTimeout);
|
|
194
339
|
try {
|
|
340
|
+
const headers: Record<string, string> = {
|
|
341
|
+
"Content-Type": "application/json",
|
|
342
|
+
};
|
|
343
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
195
344
|
const res = await fetch(resolveApiUrl("/api/console/request"), {
|
|
196
345
|
method: "POST",
|
|
197
|
-
headers
|
|
198
|
-
Authorization: `Bearer ${token}`,
|
|
199
|
-
"Content-Type": "application/json",
|
|
200
|
-
},
|
|
346
|
+
headers,
|
|
201
347
|
body: JSON.stringify(body),
|
|
202
348
|
signal: AbortSignal.timeout(this.config.requestTimeout),
|
|
203
349
|
});
|
|
204
|
-
clearTimeout(timer);
|
|
205
350
|
const json = (await res.json()) as {
|
|
206
351
|
success?: boolean;
|
|
207
352
|
data?: T;
|
|
@@ -213,7 +358,6 @@ export class WebSocketManager {
|
|
|
213
358
|
}
|
|
214
359
|
return json.data as T;
|
|
215
360
|
} catch (error) {
|
|
216
|
-
clearTimeout(timer);
|
|
217
361
|
if (error instanceof WebSocketError) throw error;
|
|
218
362
|
throw new MessageError("REST request failed", error as Error);
|
|
219
363
|
}
|
|
@@ -335,7 +479,9 @@ export class WebSocketManager {
|
|
|
335
479
|
|
|
336
480
|
private handleMessage(event: MessageEvent): void {
|
|
337
481
|
try {
|
|
338
|
-
const message =
|
|
482
|
+
const message = normalizeConsolePushMessage(
|
|
483
|
+
JSON.parse(event.data) as WebSocketMessage,
|
|
484
|
+
) as WebSocketMessage;
|
|
339
485
|
if (message.requestId && this.pendingRequests.has(message.requestId)) {
|
|
340
486
|
this.handleRequestResponse(message);
|
|
341
487
|
return;
|
|
@@ -350,18 +496,22 @@ export class WebSocketManager {
|
|
|
350
496
|
|
|
351
497
|
private handleBroadcast(message: WebSocketMessage): void {
|
|
352
498
|
const t = message.type;
|
|
353
|
-
if (
|
|
499
|
+
if (
|
|
500
|
+
t === SIDE_EVENT_PUSH.REQUEST_RECEIVE
|
|
501
|
+
|| t === SIDE_EVENT_PUSH.NOTICE_RECEIVE
|
|
502
|
+
|| t === SIDE_EVENT_PUSH.MESSAGE_RECEIVE
|
|
503
|
+
) {
|
|
354
504
|
if (typeof window !== "undefined") {
|
|
355
505
|
window.dispatchEvent(new CustomEvent("zhin-console-bot-push", { detail: message }));
|
|
356
506
|
}
|
|
357
507
|
return;
|
|
358
508
|
}
|
|
359
|
-
if (t === "hmr:reload") {
|
|
509
|
+
if (t === "hmr:reload" && message.delivery !== 'history') {
|
|
360
510
|
console.info(`[HMR] File changed, reloading...`);
|
|
361
511
|
window.location.reload();
|
|
362
512
|
return;
|
|
363
513
|
}
|
|
364
|
-
if (t === "system:restarting") {
|
|
514
|
+
if (t === "system:restarting" && message.delivery !== 'history') {
|
|
365
515
|
console.info("[System] Server restarting, will reload shortly...");
|
|
366
516
|
setTimeout(() => window.location.reload(), 3000);
|
|
367
517
|
return;
|
|
@@ -391,8 +541,10 @@ export class WebSocketManager {
|
|
|
391
541
|
}
|
|
392
542
|
|
|
393
543
|
private handleConnectionError(error: Error): void {
|
|
394
|
-
|
|
395
|
-
|
|
544
|
+
// Used by the legacy WS path. Do NOT clear the reconnect timer here —
|
|
545
|
+
// callers that still want to retry should set RECONNECTING then call
|
|
546
|
+
// scheduleReconnect(). Terminal failures go through scheduleReconnect's
|
|
547
|
+
// maxAttempts branch which sets ERROR.
|
|
396
548
|
console.error("[Console transport] Connection error:", error);
|
|
397
549
|
this.callbacks.onError?.(error as unknown as Event);
|
|
398
550
|
}
|
|
@@ -400,12 +552,23 @@ export class WebSocketManager {
|
|
|
400
552
|
private scheduleReconnect(): void {
|
|
401
553
|
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
402
554
|
this.setState(ConnectionState.ERROR);
|
|
555
|
+
this.notifyConnection(false);
|
|
403
556
|
return;
|
|
404
557
|
}
|
|
405
558
|
this.reconnectAttempts++;
|
|
559
|
+
// Keep / restore RECONNECTING so the timer callback's state guard passes.
|
|
560
|
+
if (this.state !== ConnectionState.RECONNECTING) {
|
|
561
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
562
|
+
}
|
|
406
563
|
const delay = this.config.reconnectInterval * this.reconnectAttempts;
|
|
564
|
+
this.clearReconnectTimer();
|
|
407
565
|
this.reconnectTimer = setTimeout(() => {
|
|
408
|
-
if (
|
|
566
|
+
if (
|
|
567
|
+
this.state === ConnectionState.RECONNECTING
|
|
568
|
+
|| this.state === ConnectionState.DISCONNECTED
|
|
569
|
+
) {
|
|
570
|
+
this.connect();
|
|
571
|
+
}
|
|
409
572
|
}, delay);
|
|
410
573
|
}
|
|
411
574
|
|
|
@@ -424,3 +587,31 @@ export class WebSocketManager {
|
|
|
424
587
|
this.pendingRequests.clear();
|
|
425
588
|
}
|
|
426
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
|
+
}
|
|
@@ -3,7 +3,7 @@ const TOKEN_KEY = "zhin_api_token";
|
|
|
3
3
|
|
|
4
4
|
export function getApiBase(): string {
|
|
5
5
|
const stored = localStorage.getItem(API_BASE_KEY)?.trim();
|
|
6
|
-
if (stored) return stored
|
|
6
|
+
if (stored) return trimTrailingSlash(stored);
|
|
7
7
|
if (typeof window !== "undefined") {
|
|
8
8
|
return window.location.origin;
|
|
9
9
|
}
|
|
@@ -11,11 +11,23 @@ export function getApiBase(): string {
|
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export function setApiBase(base: string): void {
|
|
14
|
-
localStorage.setItem(API_BASE_KEY, base
|
|
14
|
+
localStorage.setItem(API_BASE_KEY, trimTrailingSlash(base));
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
export function getStoredToken(): string | null {
|
|
18
|
-
|
|
18
|
+
const runtime = getRuntimeToken();
|
|
19
|
+
if (runtime) return runtime;
|
|
20
|
+
if (typeof localStorage !== "undefined") {
|
|
21
|
+
const local = localStorage.getItem(TOKEN_KEY)?.trim();
|
|
22
|
+
if (local) return local;
|
|
23
|
+
}
|
|
24
|
+
// Some login flows only persist to sessionStorage; keep transport in sync
|
|
25
|
+
// with sandbox page / agent-stream which already read both stores.
|
|
26
|
+
if (typeof sessionStorage !== "undefined") {
|
|
27
|
+
const session = sessionStorage.getItem(TOKEN_KEY)?.trim();
|
|
28
|
+
if (session) return session;
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
19
31
|
}
|
|
20
32
|
|
|
21
33
|
export function resolveApiUrl(path: string): string {
|
|
@@ -23,3 +35,13 @@ export function resolveApiUrl(path: string): string {
|
|
|
23
35
|
const p = path.startsWith("/") ? path : `/${path}`;
|
|
24
36
|
return `${base}${p}`;
|
|
25
37
|
}
|
|
38
|
+
|
|
39
|
+
function trimTrailingSlash(value: string): string {
|
|
40
|
+
return value.endsWith("/") ? value.slice(0, -1) : value;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function getRuntimeToken(): string | null {
|
|
44
|
+
if (typeof window === "undefined") return null;
|
|
45
|
+
const token = (window as unknown as { __ZHIN_API_TOKEN?: string }).__ZHIN_API_TOKEN;
|
|
46
|
+
return token?.trim() || null;
|
|
47
|
+
}
|
|
@@ -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
|
}
|