@zhin.js/client 1.1.4 → 1.1.6
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 +94 -432
- package/client/agent-stream.ts +121 -0
- package/client/app.test.ts +37 -0
- package/client/app.ts +130 -138
- package/client/bootstrap/loadConsoleEntries.test.ts +23 -12
- package/client/bootstrap/loadConsoleEntries.ts +40 -27
- package/client/console-client-context.ts +24 -0
- package/client/console-client.test.ts +46 -0
- package/client/console-client.ts +70 -0
- package/client/console-events.ts +38 -0
- package/client/console-utils/remoteApi.test.ts +91 -0
- package/client/console-utils/remoteApi.ts +47 -9
- package/client/index.ts +92 -28
- 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 +121 -0
- package/client/persistence/idb-store.ts +40 -21
- package/client/segments.test.ts +16 -0
- package/client/segments.ts +39 -0
- package/client/transport/console-transport.test.ts +394 -0
- package/client/transport/console-transport.ts +676 -0
- package/client/transport/hooks.test.ts +21 -0
- package/client/transport/hooks.ts +535 -0
- package/client/transport/index.ts +23 -0
- package/client/{websocket → transport}/types.ts +16 -17
- package/client/types.ts +5 -8
- package/client/workroom/WorkroomRunsPage.tsx +355 -0
- package/client/workroom/registerWorkroomConsole.test.ts +25 -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/app.d.ts +17 -52
- package/dist/app.d.ts.map +1 -1
- package/dist/app.js +110 -95
- package/dist/app.js.map +1 -1
- package/dist/bootstrap/loadConsoleEntries.d.ts +10 -7
- package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
- package/dist/bootstrap/loadConsoleEntries.js +37 -11
- package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
- package/dist/console-client-context.d.ts +9 -0
- package/dist/console-client-context.d.ts.map +1 -0
- package/dist/console-client-context.js +13 -0
- package/dist/console-client-context.js.map +1 -0
- package/dist/console-client.d.ts +25 -0
- package/dist/console-client.d.ts.map +1 -0
- package/dist/console-client.js +45 -0
- package/dist/console-client.js.map +1 -0
- package/dist/console-events.d.ts +8 -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 -1
- package/dist/console-utils/remoteApi.d.ts.map +1 -1
- package/dist/console-utils/remoteApi.js +46 -8
- package/dist/console-utils/remoteApi.js.map +1 -1
- package/dist/index.d.ts +14 -7
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +18 -8
- 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 +29 -20
- package/dist/persistence/idb-store.js.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/transport/console-transport.d.ts +217 -0
- package/dist/transport/console-transport.d.ts.map +1 -0
- package/dist/transport/console-transport.js +563 -0
- package/dist/transport/console-transport.js.map +1 -0
- package/dist/transport/hooks.d.ts +253 -0
- package/dist/transport/hooks.d.ts.map +1 -0
- package/dist/transport/hooks.js +483 -0
- package/dist/transport/hooks.js.map +1 -0
- package/dist/transport/index.d.ts +4 -0
- package/dist/transport/index.d.ts.map +1 -0
- package/dist/transport/index.js +4 -0
- package/dist/transport/index.js.map +1 -0
- package/dist/{websocket → transport}/types.d.ts +14 -13
- package/dist/transport/types.d.ts.map +1 -0
- package/dist/{websocket → transport}/types.js +4 -9
- package/dist/transport/types.js.map +1 -0
- package/dist/types.d.ts +4 -4
- package/dist/types.d.ts.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/client/runtime/index.ts +0 -12
- package/client/websocket/hooks.ts +0 -289
- package/client/websocket/index.ts +0 -6
- package/client/websocket/instance.ts +0 -24
- package/client/websocket/manager.ts +0 -426
- package/client/websocket/remote-settings.ts +0 -25
- 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
- package/dist/runtime/index.d.ts +0 -6
- package/dist/runtime/index.d.ts.map +0 -1
- package/dist/runtime/index.js +0 -10
- package/dist/runtime/index.js.map +0 -1
- package/dist/websocket/hooks.d.ts +0 -105
- package/dist/websocket/hooks.d.ts.map +0 -1
- package/dist/websocket/hooks.js +0 -247
- package/dist/websocket/hooks.js.map +0 -1
- package/dist/websocket/index.d.ts +0 -7
- package/dist/websocket/index.d.ts.map +0 -1
- package/dist/websocket/index.js +0 -7
- package/dist/websocket/index.js.map +0 -1
- package/dist/websocket/instance.d.ts +0 -5
- package/dist/websocket/instance.d.ts.map +0 -1
- package/dist/websocket/instance.js +0 -21
- package/dist/websocket/instance.js.map +0 -1
- package/dist/websocket/manager.d.ts +0 -113
- package/dist/websocket/manager.d.ts.map +0 -1
- package/dist/websocket/manager.js +0 -403
- package/dist/websocket/manager.js.map +0 -1
- package/dist/websocket/remote-settings.d.ts +0 -5
- package/dist/websocket/remote-settings.d.ts.map +0 -1
- package/dist/websocket/remote-settings.js +0 -23
- package/dist/websocket/remote-settings.js.map +0 -1
- package/dist/websocket/types.d.ts.map +0 -1
- package/dist/websocket/types.js.map +0 -1
|
@@ -0,0 +1,676 @@
|
|
|
1
|
+
import {
|
|
2
|
+
ConnectionState,
|
|
3
|
+
ConnectionError,
|
|
4
|
+
ConsoleTransportError,
|
|
5
|
+
MessageError,
|
|
6
|
+
type ConsoleTransportMessage,
|
|
7
|
+
type ConsoleTransportConfig,
|
|
8
|
+
type ConsoleTransportCallbacks,
|
|
9
|
+
} from './types.js';
|
|
10
|
+
import { getApiBase, getToken, resolveApiUrl } from '../console-utils/remoteApi.js';
|
|
11
|
+
import { applyConsoleEvent } from "../persistence/idb-store.js";
|
|
12
|
+
import { fetchConsoleEventHistory } from '../console-events.js';
|
|
13
|
+
import {
|
|
14
|
+
CONFIG_RPC,
|
|
15
|
+
PLUGIN_RPC,
|
|
16
|
+
ENDPOINT_RPC,
|
|
17
|
+
CONSOLE_EVENT_RECOVERY_GAP_EVENT,
|
|
18
|
+
SIDE_EVENT_PUSH,
|
|
19
|
+
parseConsoleSseFrame,
|
|
20
|
+
type ConsoleEventData,
|
|
21
|
+
type ConsoleEventEnvelope,
|
|
22
|
+
type ConsoleEventHistoryPage,
|
|
23
|
+
type ConsoleEventHistoryQuery,
|
|
24
|
+
type ConsoleConfigSource,
|
|
25
|
+
} from "@zhin.js/console-protocol";
|
|
26
|
+
|
|
27
|
+
interface ConsoleEventCursor {
|
|
28
|
+
readonly runtimeId: string;
|
|
29
|
+
readonly eventId: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type ConsoleEventListener<Type extends string> = (
|
|
33
|
+
event: ConsoleEventEnvelope<Type, ConsoleEventData<Type>>,
|
|
34
|
+
) => void;
|
|
35
|
+
export interface ConsoleEventRecoveryGap {
|
|
36
|
+
readonly query: ConsoleEventHistoryQuery;
|
|
37
|
+
readonly page: ConsoleEventHistoryPage;
|
|
38
|
+
}
|
|
39
|
+
export type ConsoleEventRecoveryGapListener = (gap: ConsoleEventRecoveryGap) => void;
|
|
40
|
+
|
|
41
|
+
export class ConsoleTransport {
|
|
42
|
+
private config: Required<ConsoleTransportConfig>;
|
|
43
|
+
private callbacks: ConsoleTransportCallbacks;
|
|
44
|
+
private state: ConnectionState = ConnectionState.DISCONNECTED;
|
|
45
|
+
private reconnectAttempts = 0;
|
|
46
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
47
|
+
private requestId = 0;
|
|
48
|
+
private connectedListeners = new Set<(connected: boolean) => void>();
|
|
49
|
+
private eventListeners = new Map<string, Set<ConsoleEventListener<string>>>();
|
|
50
|
+
private recoveryGapListeners = new Set<ConsoleEventRecoveryGapListener>();
|
|
51
|
+
private sseAbort: AbortController | null = null;
|
|
52
|
+
private eventRuntimeId = '';
|
|
53
|
+
private lastEventId = 0;
|
|
54
|
+
private durableCursorBlocked = false;
|
|
55
|
+
private disposed = false;
|
|
56
|
+
private connectionGeneration = 0;
|
|
57
|
+
|
|
58
|
+
constructor(config: ConsoleTransportConfig = {}, callbacks: ConsoleTransportCallbacks = {}) {
|
|
59
|
+
this.config = {
|
|
60
|
+
fetch: config.fetch ?? globalThis.fetch,
|
|
61
|
+
reconnectInterval: config.reconnectInterval ?? 3000,
|
|
62
|
+
maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
|
|
63
|
+
requestTimeout: config.requestTimeout ?? 10000,
|
|
64
|
+
};
|
|
65
|
+
this.callbacks = callbacks;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
onConnectionChange(listener: (connected: boolean) => void): () => void {
|
|
69
|
+
this.assertActive();
|
|
70
|
+
this.connectedListeners.add(listener);
|
|
71
|
+
return () => this.connectedListeners.delete(listener);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Subscribe by event name; known names infer their exact payload type. */
|
|
75
|
+
onConsoleEvent<Type extends string>(type: Type, listener: ConsoleEventListener<Type>): () => void {
|
|
76
|
+
this.assertActive();
|
|
77
|
+
let listeners = this.eventListeners.get(type);
|
|
78
|
+
if (!listeners) {
|
|
79
|
+
listeners = new Set();
|
|
80
|
+
this.eventListeners.set(type, listeners);
|
|
81
|
+
}
|
|
82
|
+
const stored = listener as ConsoleEventListener<string>;
|
|
83
|
+
listeners.add(stored);
|
|
84
|
+
return () => {
|
|
85
|
+
listeners?.delete(stored);
|
|
86
|
+
if (listeners?.size === 0) this.eventListeners.delete(type);
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Observe a non-resumable cursor so domain views can perform a full resync. */
|
|
91
|
+
onConsoleEventRecoveryGap(listener: ConsoleEventRecoveryGapListener): () => void {
|
|
92
|
+
this.assertActive();
|
|
93
|
+
this.recoveryGapListeners.add(listener);
|
|
94
|
+
return () => this.recoveryGapListeners.delete(listener);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
private notifyConnection(connected: boolean) {
|
|
98
|
+
for (const l of this.connectedListeners) l(connected);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
connect(): void {
|
|
102
|
+
this.assertActive();
|
|
103
|
+
if (this.state === ConnectionState.CONNECTED || this.state === ConnectionState.CONNECTING) return;
|
|
104
|
+
const generation = ++this.connectionGeneration;
|
|
105
|
+
void this.connectSse(generation);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
private async connectSse(generation: number): Promise<void> {
|
|
109
|
+
// Host without http.token allows unauthenticated SSE (authenticateHttp
|
|
110
|
+
// returns full when TokenRegistry is empty). Still open the stream so
|
|
111
|
+
// local dev Console receives hmr:reload / message.receive broadcasts.
|
|
112
|
+
const token = getToken();
|
|
113
|
+
this.setState(ConnectionState.CONNECTING);
|
|
114
|
+
this.sseAbort?.abort();
|
|
115
|
+
this.sseAbort = new AbortController();
|
|
116
|
+
try {
|
|
117
|
+
const headers: Record<string, string> = {
|
|
118
|
+
Accept: "text/event-stream",
|
|
119
|
+
};
|
|
120
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
121
|
+
await this.recoverEventHistory(this.sseAbort.signal);
|
|
122
|
+
if (!this.isCurrentConnection(generation)) return;
|
|
123
|
+
const params = new URLSearchParams();
|
|
124
|
+
if (this.eventRuntimeId) params.set('runtimeId', this.eventRuntimeId);
|
|
125
|
+
if (this.lastEventId > 0) params.set('after', String(this.lastEventId));
|
|
126
|
+
const suffix = params.size ? `?${params}` : '';
|
|
127
|
+
const res = await this.config.fetch(resolveApiUrl(`/api/events${suffix}`), {
|
|
128
|
+
headers,
|
|
129
|
+
signal: this.sseAbort.signal,
|
|
130
|
+
});
|
|
131
|
+
if (!this.isCurrentConnection(generation)) {
|
|
132
|
+
await res.body?.cancel();
|
|
133
|
+
return;
|
|
134
|
+
}
|
|
135
|
+
if (!res.ok || !res.body) {
|
|
136
|
+
throw new ConnectionError(`SSE failed: HTTP ${res.status}`);
|
|
137
|
+
}
|
|
138
|
+
this.setState(ConnectionState.CONNECTED);
|
|
139
|
+
this.reconnectAttempts = 0;
|
|
140
|
+
this.notifyConnection(true);
|
|
141
|
+
this.callbacks.onConnect?.();
|
|
142
|
+
void this.pumpSse(res.body, generation);
|
|
143
|
+
} catch (error) {
|
|
144
|
+
if (!this.isCurrentConnection(generation)) return;
|
|
145
|
+
if ((error as Error).name === "AbortError") return;
|
|
146
|
+
// Reconnect timers only run while the transport remains in RECONNECTING.
|
|
147
|
+
const connectionError = error instanceof Error ? error : new Error(String(error));
|
|
148
|
+
this.callbacks.onError?.(connectionError);
|
|
149
|
+
console.error("[Console transport] SSE connect error:", connectionError);
|
|
150
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
151
|
+
this.notifyConnection(false);
|
|
152
|
+
this.scheduleReconnect();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
private async pumpSse(
|
|
157
|
+
body: ReadableStream<Uint8Array>,
|
|
158
|
+
generation: number,
|
|
159
|
+
): Promise<void> {
|
|
160
|
+
const reader = body.getReader();
|
|
161
|
+
const decoder = new TextDecoder();
|
|
162
|
+
let buffer = "";
|
|
163
|
+
try {
|
|
164
|
+
while (true) {
|
|
165
|
+
const { done, value } = await reader.read();
|
|
166
|
+
if (done || !this.isCurrentConnection(generation)) break;
|
|
167
|
+
buffer += decoder.decode(value, { stream: true });
|
|
168
|
+
const parts = buffer.split(/\r?\n\r?\n/u);
|
|
169
|
+
buffer = parts.pop() ?? "";
|
|
170
|
+
for (const part of parts) {
|
|
171
|
+
const parsed = parseConsoleSseFrame(part);
|
|
172
|
+
if (!parsed) continue;
|
|
173
|
+
const runtimeId = parsed.runtimeId ?? this.eventRuntimeId;
|
|
174
|
+
const event: ConsoleEventEnvelope = Object.freeze({
|
|
175
|
+
runtimeId,
|
|
176
|
+
eventId: parsed.eventId ?? 0,
|
|
177
|
+
type: parsed.type,
|
|
178
|
+
data: parsed.data,
|
|
179
|
+
timestamp: parsed.timestamp ?? Date.now(),
|
|
180
|
+
delivery: 'live',
|
|
181
|
+
});
|
|
182
|
+
await this.deliverConsoleEvent(event);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
/* closed */
|
|
187
|
+
} finally {
|
|
188
|
+
if (this.isCurrentConnection(generation) && this.state === ConnectionState.CONNECTED) {
|
|
189
|
+
this.notifyConnection(false);
|
|
190
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
191
|
+
this.callbacks.onDisconnect?.();
|
|
192
|
+
this.scheduleReconnect();
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private async recoverEventHistory(signal: AbortSignal): Promise<void> {
|
|
198
|
+
const stored = this.eventRuntimeId
|
|
199
|
+
? { runtimeId: this.eventRuntimeId, eventId: this.lastEventId }
|
|
200
|
+
: readEventCursor();
|
|
201
|
+
let runtimeId = stored?.runtimeId;
|
|
202
|
+
let after = stored?.eventId ?? 0;
|
|
203
|
+
if (runtimeId) {
|
|
204
|
+
this.eventRuntimeId = runtimeId;
|
|
205
|
+
this.lastEventId = after;
|
|
206
|
+
}
|
|
207
|
+
for (;;) {
|
|
208
|
+
const query = { runtimeId, after, limit: 500 } satisfies ConsoleEventHistoryQuery;
|
|
209
|
+
const page = await fetchConsoleEventHistory(query, { signal, fetch: this.config.fetch });
|
|
210
|
+
if (page.runtimeId !== runtimeId) {
|
|
211
|
+
runtimeId = page.runtimeId;
|
|
212
|
+
after = 0;
|
|
213
|
+
this.lastEventId = 0;
|
|
214
|
+
this.durableCursorBlocked = false;
|
|
215
|
+
}
|
|
216
|
+
this.eventRuntimeId = page.runtimeId;
|
|
217
|
+
if (page.gap) this.notifyRecoveryGap({ query, page });
|
|
218
|
+
for (const item of page.items) {
|
|
219
|
+
await this.deliverConsoleEvent(Object.freeze({ ...item, delivery: 'history' }));
|
|
220
|
+
after = item.eventId;
|
|
221
|
+
}
|
|
222
|
+
if (!page.hasMore) {
|
|
223
|
+
this.lastEventId = Math.max(after, page.latestEventId);
|
|
224
|
+
if (!this.durableCursorBlocked) {
|
|
225
|
+
writeEventCursor({ runtimeId: page.runtimeId, eventId: this.lastEventId });
|
|
226
|
+
}
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
after = page.nextAfter;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async deliverConsoleEvent(event: ConsoleEventEnvelope): Promise<void> {
|
|
234
|
+
if (event.runtimeId && event.runtimeId !== this.eventRuntimeId) {
|
|
235
|
+
this.eventRuntimeId = event.runtimeId;
|
|
236
|
+
this.lastEventId = 0;
|
|
237
|
+
this.durableCursorBlocked = false;
|
|
238
|
+
}
|
|
239
|
+
if (event.eventId > 0
|
|
240
|
+
&& event.runtimeId === this.eventRuntimeId
|
|
241
|
+
&& event.eventId <= this.lastEventId) return;
|
|
242
|
+
const message: ConsoleTransportMessage = Object.freeze({
|
|
243
|
+
type: event.type,
|
|
244
|
+
data: event.data,
|
|
245
|
+
runtimeId: event.runtimeId,
|
|
246
|
+
eventId: event.eventId || undefined,
|
|
247
|
+
timestamp: event.timestamp,
|
|
248
|
+
delivery: event.delivery,
|
|
249
|
+
});
|
|
250
|
+
try {
|
|
251
|
+
await applyConsoleEvent(message);
|
|
252
|
+
} catch (error) {
|
|
253
|
+
this.durableCursorBlocked = true;
|
|
254
|
+
console.error('[Console transport] Failed to persist event:', error);
|
|
255
|
+
}
|
|
256
|
+
this.handleBroadcast(message);
|
|
257
|
+
try {
|
|
258
|
+
this.callbacks.onMessage?.(message);
|
|
259
|
+
} catch (error) {
|
|
260
|
+
console.error('[Console transport] onMessage listener failed:', error);
|
|
261
|
+
}
|
|
262
|
+
for (const listener of this.eventListeners.get(event.type) ?? []) {
|
|
263
|
+
try {
|
|
264
|
+
listener(event as ConsoleEventEnvelope<string, unknown>);
|
|
265
|
+
} catch (error) {
|
|
266
|
+
console.error('[Console transport] event listener failed:', error);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
if (event.eventId > 0 && event.runtimeId) {
|
|
270
|
+
this.lastEventId = Math.max(this.lastEventId, event.eventId);
|
|
271
|
+
if (!this.durableCursorBlocked) {
|
|
272
|
+
writeEventCursor({ runtimeId: event.runtimeId, eventId: this.lastEventId });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
private notifyRecoveryGap(gap: ConsoleEventRecoveryGap): void {
|
|
278
|
+
if (typeof window !== 'undefined') {
|
|
279
|
+
window.dispatchEvent(new CustomEvent(CONSOLE_EVENT_RECOVERY_GAP_EVENT, { detail: gap }));
|
|
280
|
+
}
|
|
281
|
+
for (const listener of this.recoveryGapListeners) {
|
|
282
|
+
try {
|
|
283
|
+
listener(gap);
|
|
284
|
+
} catch (error) {
|
|
285
|
+
console.error('[Console transport] recovery gap listener failed:', error);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
disconnect(): void {
|
|
291
|
+
const wasConnected = this.state === ConnectionState.CONNECTED;
|
|
292
|
+
this.connectionGeneration += 1;
|
|
293
|
+
this.clearReconnectTimer();
|
|
294
|
+
this.sseAbort?.abort();
|
|
295
|
+
this.sseAbort = null;
|
|
296
|
+
this.setState(ConnectionState.DISCONNECTED);
|
|
297
|
+
this.notifyConnection(false);
|
|
298
|
+
if (wasConnected) this.callbacks.onDisconnect?.();
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
send(message: unknown): void {
|
|
302
|
+
this.assertActive();
|
|
303
|
+
void this.sendRequest(message);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async sendRequest<T = unknown>(message: unknown): Promise<T> {
|
|
307
|
+
this.assertActive();
|
|
308
|
+
// Host may run without http.token (local smoke). Only require a token when
|
|
309
|
+
// one is configured client-side; otherwise POST without Authorization and
|
|
310
|
+
// let the Host accept (TokenRegistry empty → full scope).
|
|
311
|
+
const token = getToken();
|
|
312
|
+
const requestId = ++this.requestId;
|
|
313
|
+
const body = { ...(message as Record<string, unknown>), requestId };
|
|
314
|
+
try {
|
|
315
|
+
const headers: Record<string, string> = {
|
|
316
|
+
"Content-Type": "application/json",
|
|
317
|
+
};
|
|
318
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
319
|
+
const res = await this.config.fetch(resolveApiUrl("/api/console/request"), {
|
|
320
|
+
method: "POST",
|
|
321
|
+
headers,
|
|
322
|
+
body: JSON.stringify(body),
|
|
323
|
+
signal: AbortSignal.timeout(this.config.requestTimeout),
|
|
324
|
+
});
|
|
325
|
+
const json = (await res.json()) as {
|
|
326
|
+
success?: boolean;
|
|
327
|
+
data?: T;
|
|
328
|
+
error?: string;
|
|
329
|
+
requestId?: number;
|
|
330
|
+
};
|
|
331
|
+
if (!res.ok || json.success === false) {
|
|
332
|
+
throw new ConsoleTransportError(json.error ?? `HTTP ${res.status}`, "SERVER_ERROR");
|
|
333
|
+
}
|
|
334
|
+
return json.data as T;
|
|
335
|
+
} catch (error) {
|
|
336
|
+
if (error instanceof ConsoleTransportError) throw error;
|
|
337
|
+
throw new MessageError("REST request failed", error as Error);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
isConnected(): boolean {
|
|
342
|
+
return this.state === ConnectionState.CONNECTED;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
getState(): ConnectionState {
|
|
346
|
+
return this.state;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
dispose(): void {
|
|
350
|
+
if (this.disposed) return;
|
|
351
|
+
this.disconnect();
|
|
352
|
+
this.disposed = true;
|
|
353
|
+
this.connectedListeners.clear();
|
|
354
|
+
this.eventListeners.clear();
|
|
355
|
+
this.recoveryGapListeners.clear();
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
async getConfig(pluginName: string) {
|
|
359
|
+
return this.sendRequest<unknown>({ type: CONFIG_RPC.GET, pluginName });
|
|
360
|
+
}
|
|
361
|
+
async planPluginInstall(packageName: string) {
|
|
362
|
+
return this.sendRequest<{
|
|
363
|
+
packageName: string;
|
|
364
|
+
instanceKey: string;
|
|
365
|
+
alreadyDeclared: boolean;
|
|
366
|
+
alreadyInstalled: boolean;
|
|
367
|
+
restartRequired: boolean;
|
|
368
|
+
changes: { packageManifest: string; config: string };
|
|
369
|
+
warnings: string[];
|
|
370
|
+
}>({ type: PLUGIN_RPC.PLAN_INSTALL, packageName });
|
|
371
|
+
}
|
|
372
|
+
async installPlugin(packageName: string, expectedRevision?: string) {
|
|
373
|
+
return this.sendRequest<{
|
|
374
|
+
success: boolean;
|
|
375
|
+
plan: Awaited<ReturnType<ConsoleTransport['planPluginInstall']>>;
|
|
376
|
+
restartRequired: boolean;
|
|
377
|
+
}>({ type: PLUGIN_RPC.INSTALL, packageName, expectedRevision });
|
|
378
|
+
}
|
|
379
|
+
async planPluginUninstall(packageName: string) {
|
|
380
|
+
return this.sendRequest<{
|
|
381
|
+
packageName: string;
|
|
382
|
+
instanceKey: string;
|
|
383
|
+
installed: boolean;
|
|
384
|
+
declared: boolean;
|
|
385
|
+
hasConfig: boolean;
|
|
386
|
+
restartRequired: boolean;
|
|
387
|
+
}>({ type: PLUGIN_RPC.PLAN_UNINSTALL, packageName });
|
|
388
|
+
}
|
|
389
|
+
async uninstallPlugin(packageName: string, expectedRevision?: string) {
|
|
390
|
+
return this.sendRequest<{
|
|
391
|
+
success: boolean;
|
|
392
|
+
plan: Awaited<ReturnType<ConsoleTransport['planPluginUninstall']>>;
|
|
393
|
+
restartRequired: boolean;
|
|
394
|
+
}>({
|
|
395
|
+
type: PLUGIN_RPC.UNINSTALL,
|
|
396
|
+
packageName,
|
|
397
|
+
confirmation: packageName,
|
|
398
|
+
expectedRevision,
|
|
399
|
+
});
|
|
400
|
+
}
|
|
401
|
+
async planPluginUpdate(packageName: string, targetVersion: string) {
|
|
402
|
+
return this.sendRequest<{
|
|
403
|
+
packageName: string;
|
|
404
|
+
instanceKey: string;
|
|
405
|
+
currentVersion: string | null;
|
|
406
|
+
targetVersion: string;
|
|
407
|
+
installed: boolean;
|
|
408
|
+
declared: boolean;
|
|
409
|
+
alreadyCurrent: boolean;
|
|
410
|
+
restartRequired: boolean;
|
|
411
|
+
}>({ type: PLUGIN_RPC.PLAN_UPDATE, packageName, targetVersion });
|
|
412
|
+
}
|
|
413
|
+
async updatePlugin(packageName: string, targetVersion: string, expectedRevision?: string) {
|
|
414
|
+
return this.sendRequest<{
|
|
415
|
+
success: boolean;
|
|
416
|
+
plan: Awaited<ReturnType<ConsoleTransport['planPluginUpdate']>>;
|
|
417
|
+
installedVersion: string;
|
|
418
|
+
restartRequired: boolean;
|
|
419
|
+
}>({ type: PLUGIN_RPC.UPDATE, packageName, targetVersion, expectedRevision });
|
|
420
|
+
}
|
|
421
|
+
async validatePluginConfig(pluginName: string, data: unknown) {
|
|
422
|
+
return this.sendRequest<{
|
|
423
|
+
valid: boolean;
|
|
424
|
+
errors: Array<{ path: string; message: string }>;
|
|
425
|
+
missingEnv: string[];
|
|
426
|
+
}>({ type: PLUGIN_RPC.VALIDATE_CONFIG, pluginName, data });
|
|
427
|
+
}
|
|
428
|
+
async diagnosePlugin(pluginName: string) {
|
|
429
|
+
return this.sendRequest<{
|
|
430
|
+
pluginName: string;
|
|
431
|
+
plan: unknown;
|
|
432
|
+
validation: { valid: boolean; errors: Array<{ path: string; message: string }>; missingEnv: string[] };
|
|
433
|
+
}>({ type: PLUGIN_RPC.DIAGNOSE, pluginName });
|
|
434
|
+
}
|
|
435
|
+
async setPluginEnabled(instanceKey: string, enabled: boolean) {
|
|
436
|
+
return this.sendRequest<{
|
|
437
|
+
success: boolean;
|
|
438
|
+
instanceKey: string;
|
|
439
|
+
enabled: boolean;
|
|
440
|
+
disabled: string[];
|
|
441
|
+
restartRequired: boolean;
|
|
442
|
+
message: string;
|
|
443
|
+
}>({ type: PLUGIN_RPC.SET_ENABLED, instanceKey, enabled });
|
|
444
|
+
}
|
|
445
|
+
async listPlugins() {
|
|
446
|
+
return this.getRest<Array<Record<string, unknown>>>('/api/plugins');
|
|
447
|
+
}
|
|
448
|
+
async searchMarketplace(query: {
|
|
449
|
+
keyword?: string;
|
|
450
|
+
category?: string;
|
|
451
|
+
official?: boolean;
|
|
452
|
+
page?: number;
|
|
453
|
+
pageSize?: number;
|
|
454
|
+
} = {}) {
|
|
455
|
+
const params = new URLSearchParams();
|
|
456
|
+
const normalized = {
|
|
457
|
+
q: query.keyword,
|
|
458
|
+
category: query.category,
|
|
459
|
+
official: query.official,
|
|
460
|
+
page: query.page,
|
|
461
|
+
size: query.pageSize,
|
|
462
|
+
};
|
|
463
|
+
for (const [key, value] of Object.entries(normalized)) {
|
|
464
|
+
if (value !== undefined) params.set(key, String(value));
|
|
465
|
+
}
|
|
466
|
+
return this.getRest<unknown>(`/pub/marketplace/search${params.size ? `?${params}` : ''}`);
|
|
467
|
+
}
|
|
468
|
+
async getMarketplacePlugin(packageName: string) {
|
|
469
|
+
return this.getRest<unknown>(`/pub/marketplace/detail/${encodeURIComponent(packageName)}`);
|
|
470
|
+
}
|
|
471
|
+
async getPluginUpdates() {
|
|
472
|
+
return this.getRest<Array<Record<string, unknown>>>('/api/marketplace/updates');
|
|
473
|
+
}
|
|
474
|
+
async testEndpoint(adapter: string, endpointKey: string) {
|
|
475
|
+
return this.sendRequest<{
|
|
476
|
+
reachable: boolean;
|
|
477
|
+
connected: boolean;
|
|
478
|
+
status?: 'online' | 'offline';
|
|
479
|
+
phase: string;
|
|
480
|
+
pendingLogin?: boolean;
|
|
481
|
+
latencyMs: number;
|
|
482
|
+
message: string;
|
|
483
|
+
}>({ type: ENDPOINT_RPC.TEST, adapter, endpointKey });
|
|
484
|
+
}
|
|
485
|
+
async setConfig(pluginName: string, config: unknown) {
|
|
486
|
+
return this.sendRequest<{ success?: boolean; reloaded?: boolean; message?: string }>({
|
|
487
|
+
type: CONFIG_RPC.SET,
|
|
488
|
+
pluginName,
|
|
489
|
+
data: config,
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
async getSchema(pluginName: string) {
|
|
493
|
+
return this.sendRequest<unknown>({ type: "schema:get", pluginName });
|
|
494
|
+
}
|
|
495
|
+
async getAllConfigs() {
|
|
496
|
+
return this.sendRequest<Record<string, unknown>>({ type: CONFIG_RPC.GET_ALL });
|
|
497
|
+
}
|
|
498
|
+
async getAllSchemas() {
|
|
499
|
+
return this.sendRequest<Record<string, unknown>>({ type: "schema:get-all" });
|
|
500
|
+
}
|
|
501
|
+
async getConfigSource() {
|
|
502
|
+
return this.sendRequest<ConsoleConfigSource>({ type: CONFIG_RPC.GET_SOURCE });
|
|
503
|
+
}
|
|
504
|
+
async replaceConfigSource(source: string, expectedRevision: string) {
|
|
505
|
+
return this.sendRequest<{
|
|
506
|
+
success: boolean;
|
|
507
|
+
revision: string;
|
|
508
|
+
restartRequired: boolean;
|
|
509
|
+
message?: string;
|
|
510
|
+
}>({
|
|
511
|
+
type: CONFIG_RPC.REPLACE_SOURCE,
|
|
512
|
+
source,
|
|
513
|
+
expectedRevision,
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
async getEnvList() {
|
|
517
|
+
return this.sendRequest<{ files: Array<{ name: string; exists: boolean }> }>({ type: "env:list" });
|
|
518
|
+
}
|
|
519
|
+
async getEnvFile(filename: string) {
|
|
520
|
+
return this.sendRequest<{ content: string }>({ type: "env:get", filename });
|
|
521
|
+
}
|
|
522
|
+
async saveEnvFile(filename: string, content: string) {
|
|
523
|
+
return this.sendRequest<{ success: boolean; message?: string }>({ type: "env:save", filename, content });
|
|
524
|
+
}
|
|
525
|
+
async getFileTree() {
|
|
526
|
+
return this.sendRequest<{ tree: import("./types.js").FileTreeNode[] }>({ type: "files:tree" });
|
|
527
|
+
}
|
|
528
|
+
async readFile(filePath: string) {
|
|
529
|
+
return this.sendRequest<{ content: string; size: number }>({ type: "files:read", filePath });
|
|
530
|
+
}
|
|
531
|
+
async saveFile(filePath: string, content: string) {
|
|
532
|
+
return this.sendRequest<{ success: boolean; message?: string }>({ type: "files:save", filePath, content });
|
|
533
|
+
}
|
|
534
|
+
async getDbInfo() {
|
|
535
|
+
return this.sendRequest<import("./types.js").DatabaseInfo>({ type: "db:info" });
|
|
536
|
+
}
|
|
537
|
+
async getDbTables() {
|
|
538
|
+
return this.sendRequest<{ tables: import("./types.js").TableInfo[] }>({ type: "db:tables" });
|
|
539
|
+
}
|
|
540
|
+
async dbSelect(table: string, page?: number, pageSize?: number, where?: unknown) {
|
|
541
|
+
return this.sendRequest<import("./types.js").SelectResult>({ type: "db:select", table, page, pageSize, where });
|
|
542
|
+
}
|
|
543
|
+
async dbInsert(table: string, row: unknown) {
|
|
544
|
+
return this.sendRequest<{ success: boolean }>({ type: "db:insert", table, row });
|
|
545
|
+
}
|
|
546
|
+
async dbUpdate(table: string, row: unknown, where: unknown) {
|
|
547
|
+
return this.sendRequest<{ success: boolean; affected: number }>({ type: "db:update", table, row, where });
|
|
548
|
+
}
|
|
549
|
+
async dbDelete(table: string, where: unknown) {
|
|
550
|
+
return this.sendRequest<{ success: boolean; deleted: number }>({ type: "db:delete", table, where });
|
|
551
|
+
}
|
|
552
|
+
async dbDropTable(table: string) {
|
|
553
|
+
return this.sendRequest<{ success: boolean }>({ type: "db:drop-table", table });
|
|
554
|
+
}
|
|
555
|
+
async kvGet(table: string, key: string) {
|
|
556
|
+
return this.sendRequest<{ key: string; value: unknown }>({ type: "db:kv:get", table, key });
|
|
557
|
+
}
|
|
558
|
+
async kvSet(table: string, key: string, value: unknown, ttl?: number) {
|
|
559
|
+
return this.sendRequest<{ success: boolean }>({ type: "db:kv:set", table, key, value, ttl });
|
|
560
|
+
}
|
|
561
|
+
async kvDelete(table: string, key: string) {
|
|
562
|
+
return this.sendRequest<{ success: boolean }>({ type: "db:kv:delete", table, key });
|
|
563
|
+
}
|
|
564
|
+
async kvGetEntries(table: string) {
|
|
565
|
+
return this.sendRequest<{ entries: import("./types.js").KvEntry[] }>({ type: "db:kv:entries", table });
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
private async getRest<T>(path: string): Promise<T> {
|
|
569
|
+
const token = getToken();
|
|
570
|
+
const headers: Record<string, string> = { Accept: 'application/json' };
|
|
571
|
+
if (token) headers.Authorization = `Bearer ${token}`;
|
|
572
|
+
const response = await this.config.fetch(resolveApiUrl(path), {
|
|
573
|
+
headers,
|
|
574
|
+
signal: AbortSignal.timeout(this.config.requestTimeout),
|
|
575
|
+
});
|
|
576
|
+
const body = await response.json() as { success?: boolean; data?: T; error?: string };
|
|
577
|
+
if (!response.ok || body.success === false) {
|
|
578
|
+
throw new ConsoleTransportError(body.error ?? `HTTP ${response.status}`, 'SERVER_ERROR');
|
|
579
|
+
}
|
|
580
|
+
return body.data as T;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
private setState(newState: ConnectionState): void {
|
|
584
|
+
this.state = newState;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
private handleBroadcast(message: ConsoleTransportMessage): void {
|
|
588
|
+
const t = message.type;
|
|
589
|
+
if (
|
|
590
|
+
t === SIDE_EVENT_PUSH.REQUEST_RECEIVE
|
|
591
|
+
|| t === SIDE_EVENT_PUSH.NOTICE_RECEIVE
|
|
592
|
+
|| t === SIDE_EVENT_PUSH.MESSAGE_RECEIVE
|
|
593
|
+
) {
|
|
594
|
+
if (typeof window !== "undefined") {
|
|
595
|
+
window.dispatchEvent(new CustomEvent("zhin-console-bot-push", { detail: message }));
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
if (t === "hmr:reload" && message.delivery !== 'history') {
|
|
600
|
+
console.info(`[HMR] File changed, reloading...`);
|
|
601
|
+
window.location.reload();
|
|
602
|
+
return;
|
|
603
|
+
}
|
|
604
|
+
if (t === "system:restarting" && message.delivery !== 'history') {
|
|
605
|
+
console.info("[System] Server restarting, will reload shortly...");
|
|
606
|
+
setTimeout(() => window.location.reload(), 3000);
|
|
607
|
+
return;
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
private scheduleReconnect(): void {
|
|
612
|
+
if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
|
|
613
|
+
this.setState(ConnectionState.ERROR);
|
|
614
|
+
this.notifyConnection(false);
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
this.reconnectAttempts++;
|
|
618
|
+
// Keep / restore RECONNECTING so the timer callback's state guard passes.
|
|
619
|
+
if (this.state !== ConnectionState.RECONNECTING) {
|
|
620
|
+
this.setState(ConnectionState.RECONNECTING);
|
|
621
|
+
}
|
|
622
|
+
const delay = this.config.reconnectInterval * this.reconnectAttempts;
|
|
623
|
+
this.clearReconnectTimer();
|
|
624
|
+
this.reconnectTimer = setTimeout(() => {
|
|
625
|
+
if (
|
|
626
|
+
this.state === ConnectionState.RECONNECTING
|
|
627
|
+
|| this.state === ConnectionState.DISCONNECTED
|
|
628
|
+
) {
|
|
629
|
+
this.connect();
|
|
630
|
+
}
|
|
631
|
+
}, delay);
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
private clearReconnectTimer(): void {
|
|
635
|
+
if (this.reconnectTimer) {
|
|
636
|
+
clearTimeout(this.reconnectTimer);
|
|
637
|
+
this.reconnectTimer = null;
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
private assertActive(): void {
|
|
642
|
+
if (this.disposed) throw new Error('ConsoleTransport has been disposed');
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
private isCurrentConnection(generation: number): boolean {
|
|
646
|
+
return !this.disposed && generation === this.connectionGeneration;
|
|
647
|
+
}
|
|
648
|
+
}
|
|
649
|
+
|
|
650
|
+
function eventCursorStorageKey(): string {
|
|
651
|
+
return `zhin.console.event-cursor:${getApiBase()}`;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function readEventCursor(): ConsoleEventCursor | null {
|
|
655
|
+
if (typeof localStorage === 'undefined') return null;
|
|
656
|
+
try {
|
|
657
|
+
const parsed = JSON.parse(localStorage.getItem(eventCursorStorageKey()) ?? 'null') as Partial<ConsoleEventCursor> | null;
|
|
658
|
+
return parsed
|
|
659
|
+
&& typeof parsed.runtimeId === 'string'
|
|
660
|
+
&& Number.isSafeInteger(parsed.eventId)
|
|
661
|
+
&& (parsed.eventId ?? -1) >= 0
|
|
662
|
+
? { runtimeId: parsed.runtimeId, eventId: parsed.eventId as number }
|
|
663
|
+
: null;
|
|
664
|
+
} catch {
|
|
665
|
+
return null;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function writeEventCursor(cursor: ConsoleEventCursor): void {
|
|
670
|
+
if (typeof localStorage === 'undefined') return;
|
|
671
|
+
try {
|
|
672
|
+
localStorage.setItem(eventCursorStorageKey(), JSON.stringify(cursor));
|
|
673
|
+
} catch {
|
|
674
|
+
// HTTP history remains available when browser storage is unavailable.
|
|
675
|
+
}
|
|
676
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
|
|
3
|
+
import { resolveKeyedValue } from "./hooks.js";
|
|
4
|
+
|
|
5
|
+
describe("resolveKeyedValue (useConfig stale-state guard)", () => {
|
|
6
|
+
it("returns the value when the entry key matches", () => {
|
|
7
|
+
const entry = { key: "plugin-a", value: { foo: 1 } };
|
|
8
|
+
expect(resolveKeyedValue(entry, "plugin-a")).toEqual({ foo: 1 });
|
|
9
|
+
});
|
|
10
|
+
|
|
11
|
+
it("treats a previous plugin's leftover state as empty after pluginName switches", () => {
|
|
12
|
+
// 回归:旧实现中 config state 跨 pluginName 残留,ready(config != null) 永远为真,
|
|
13
|
+
// 切换插件后不再自动加载新配置。
|
|
14
|
+
const stale = { key: "plugin-a", value: { foo: 1 } };
|
|
15
|
+
expect(resolveKeyedValue(stale, "plugin-b")).toBeNull();
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("returns null when nothing has been loaded", () => {
|
|
19
|
+
expect(resolveKeyedValue(null, "plugin-a")).toBeNull();
|
|
20
|
+
});
|
|
21
|
+
});
|