@zhin.js/client 1.1.5 → 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.
Files changed (99) hide show
  1. package/README.md +25 -19
  2. package/client/app.test.ts +37 -0
  3. package/client/app.ts +130 -138
  4. package/client/bootstrap/loadConsoleEntries.test.ts +0 -15
  5. package/client/bootstrap/loadConsoleEntries.ts +1 -20
  6. package/client/console-client-context.ts +24 -0
  7. package/client/console-client.test.ts +46 -0
  8. package/client/console-client.ts +70 -0
  9. package/client/console-events.ts +4 -3
  10. package/client/console-utils/remoteApi.ts +0 -7
  11. package/client/index.ts +21 -13
  12. package/client/persistence/idb-store.test.ts +0 -18
  13. package/client/persistence/idb-store.ts +3 -6
  14. package/client/{websocket/manager.test.ts → transport/console-transport.test.ts} +79 -34
  15. package/client/{websocket/manager.ts → transport/console-transport.ts} +237 -178
  16. package/client/{websocket → transport}/hooks.ts +240 -23
  17. package/client/transport/index.ts +23 -0
  18. package/client/{websocket → transport}/types.ts +11 -17
  19. package/client/types.ts +0 -3
  20. package/client/workroom/registerWorkroomConsole.test.ts +0 -1
  21. package/dist/app.d.ts +17 -52
  22. package/dist/app.d.ts.map +1 -1
  23. package/dist/app.js +110 -95
  24. package/dist/app.js.map +1 -1
  25. package/dist/bootstrap/loadConsoleEntries.d.ts +0 -6
  26. package/dist/bootstrap/loadConsoleEntries.d.ts.map +1 -1
  27. package/dist/bootstrap/loadConsoleEntries.js +0 -9
  28. package/dist/bootstrap/loadConsoleEntries.js.map +1 -1
  29. package/dist/console-client-context.d.ts +9 -0
  30. package/dist/console-client-context.d.ts.map +1 -0
  31. package/dist/console-client-context.js +13 -0
  32. package/dist/console-client-context.js.map +1 -0
  33. package/dist/console-client.d.ts +25 -0
  34. package/dist/console-client.d.ts.map +1 -0
  35. package/dist/console-client.js +45 -0
  36. package/dist/console-client.js.map +1 -0
  37. package/dist/console-events.d.ts +1 -0
  38. package/dist/console-events.d.ts.map +1 -1
  39. package/dist/console-events.js +3 -3
  40. package/dist/console-events.js.map +1 -1
  41. package/dist/console-utils/remoteApi.d.ts +0 -1
  42. package/dist/console-utils/remoteApi.d.ts.map +1 -1
  43. package/dist/console-utils/remoteApi.js +0 -6
  44. package/dist/console-utils/remoteApi.js.map +1 -1
  45. package/dist/index.d.ts +7 -6
  46. package/dist/index.d.ts.map +1 -1
  47. package/dist/index.js +9 -8
  48. package/dist/index.js.map +1 -1
  49. package/dist/persistence/idb-store.d.ts.map +1 -1
  50. package/dist/persistence/idb-store.js +3 -5
  51. package/dist/persistence/idb-store.js.map +1 -1
  52. package/dist/transport/console-transport.d.ts +217 -0
  53. package/dist/transport/console-transport.d.ts.map +1 -0
  54. package/dist/{websocket/manager.js → transport/console-transport.js} +147 -165
  55. package/dist/transport/console-transport.js.map +1 -0
  56. package/dist/transport/hooks.d.ts +253 -0
  57. package/dist/transport/hooks.d.ts.map +1 -0
  58. package/dist/{websocket → transport}/hooks.js +234 -19
  59. package/dist/transport/hooks.js.map +1 -0
  60. package/dist/transport/index.d.ts +4 -0
  61. package/dist/transport/index.d.ts.map +1 -0
  62. package/dist/transport/index.js +4 -0
  63. package/dist/transport/index.js.map +1 -0
  64. package/dist/{websocket → transport}/types.d.ts +10 -13
  65. package/dist/transport/types.d.ts.map +1 -0
  66. package/dist/{websocket → transport}/types.js +4 -9
  67. package/dist/transport/types.js.map +1 -0
  68. package/dist/types.d.ts +0 -1
  69. package/dist/types.d.ts.map +1 -1
  70. package/package.json +3 -3
  71. package/client/runtime/index.ts +0 -12
  72. package/client/websocket/index.ts +0 -11
  73. package/client/websocket/instance.ts +0 -24
  74. package/client/websocket/remote-settings.ts +0 -47
  75. package/dist/runtime/index.d.ts +0 -6
  76. package/dist/runtime/index.d.ts.map +0 -1
  77. package/dist/runtime/index.js +0 -10
  78. package/dist/runtime/index.js.map +0 -1
  79. package/dist/websocket/hooks.d.ts +0 -114
  80. package/dist/websocket/hooks.d.ts.map +0 -1
  81. package/dist/websocket/hooks.js.map +0 -1
  82. package/dist/websocket/index.d.ts +0 -7
  83. package/dist/websocket/index.d.ts.map +0 -1
  84. package/dist/websocket/index.js +0 -7
  85. package/dist/websocket/index.js.map +0 -1
  86. package/dist/websocket/instance.d.ts +0 -5
  87. package/dist/websocket/instance.d.ts.map +0 -1
  88. package/dist/websocket/instance.js +0 -21
  89. package/dist/websocket/instance.js.map +0 -1
  90. package/dist/websocket/manager.d.ts +0 -131
  91. package/dist/websocket/manager.d.ts.map +0 -1
  92. package/dist/websocket/manager.js.map +0 -1
  93. package/dist/websocket/remote-settings.d.ts +0 -5
  94. package/dist/websocket/remote-settings.d.ts.map +0 -1
  95. package/dist/websocket/remote-settings.js +0 -47
  96. package/dist/websocket/remote-settings.js.map +0 -1
  97. package/dist/websocket/types.d.ts.map +0 -1
  98. package/dist/websocket/types.js.map +0 -1
  99. /package/client/{websocket → transport}/hooks.test.ts +0 -0
@@ -1,17 +1,27 @@
1
- import { ConnectionState, WebSocketError, ConnectionError, MessageError, RequestTimeoutError, type WebSocketMessage, type WebSocketConfig, type WebSocketCallbacks } from './types';
2
-
3
- import { getApiBase, getStoredToken, resolveApiUrl } from "./remote-settings.js";
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';
4
11
  import { applyConsoleEvent } from "../persistence/idb-store.js";
5
12
  import { fetchConsoleEventHistory } from '../console-events.js';
6
13
  import {
14
+ CONFIG_RPC,
15
+ PLUGIN_RPC,
16
+ ENDPOINT_RPC,
7
17
  CONSOLE_EVENT_RECOVERY_GAP_EVENT,
8
18
  SIDE_EVENT_PUSH,
9
- normalizeConsolePushMessage,
10
19
  parseConsoleSseFrame,
11
20
  type ConsoleEventData,
12
21
  type ConsoleEventEnvelope,
13
22
  type ConsoleEventHistoryPage,
14
23
  type ConsoleEventHistoryQuery,
24
+ type ConsoleConfigSource,
15
25
  } from "@zhin.js/console-protocol";
16
26
 
17
27
  interface ConsoleEventCursor {
@@ -28,18 +38,13 @@ export interface ConsoleEventRecoveryGap {
28
38
  }
29
39
  export type ConsoleEventRecoveryGapListener = (gap: ConsoleEventRecoveryGap) => void;
30
40
 
31
- export class WebSocketManager {
32
- private ws: WebSocket | null = null;
33
- private config: Required<WebSocketConfig>;
34
- private callbacks: WebSocketCallbacks;
41
+ export class ConsoleTransport {
42
+ private config: Required<ConsoleTransportConfig>;
43
+ private callbacks: ConsoleTransportCallbacks;
35
44
  private state: ConnectionState = ConnectionState.DISCONNECTED;
36
45
  private reconnectAttempts = 0;
37
46
  private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
38
47
  private requestId = 0;
39
- private pendingRequests = new Map<
40
- number,
41
- { resolve: (value: unknown) => void; reject: (error: Error) => void; timer: ReturnType<typeof setTimeout> }
42
- >();
43
48
  private connectedListeners = new Set<(connected: boolean) => void>();
44
49
  private eventListeners = new Map<string, Set<ConsoleEventListener<string>>>();
45
50
  private recoveryGapListeners = new Set<ConsoleEventRecoveryGapListener>();
@@ -47,11 +52,12 @@ export class WebSocketManager {
47
52
  private eventRuntimeId = '';
48
53
  private lastEventId = 0;
49
54
  private durableCursorBlocked = false;
50
- private useRestTransport = true;
55
+ private disposed = false;
56
+ private connectionGeneration = 0;
51
57
 
52
- constructor(config: WebSocketConfig = {}, callbacks: WebSocketCallbacks = {}) {
58
+ constructor(config: ConsoleTransportConfig = {}, callbacks: ConsoleTransportCallbacks = {}) {
53
59
  this.config = {
54
- url: this.buildWebSocketUrl(config.url),
60
+ fetch: config.fetch ?? globalThis.fetch,
55
61
  reconnectInterval: config.reconnectInterval ?? 3000,
56
62
  maxReconnectAttempts: config.maxReconnectAttempts ?? 10,
57
63
  requestTimeout: config.requestTimeout ?? 10000,
@@ -60,12 +66,14 @@ export class WebSocketManager {
60
66
  }
61
67
 
62
68
  onConnectionChange(listener: (connected: boolean) => void): () => void {
69
+ this.assertActive();
63
70
  this.connectedListeners.add(listener);
64
71
  return () => this.connectedListeners.delete(listener);
65
72
  }
66
73
 
67
74
  /** Subscribe by event name; known names infer their exact payload type. */
68
75
  onConsoleEvent<Type extends string>(type: Type, listener: ConsoleEventListener<Type>): () => void {
76
+ this.assertActive();
69
77
  let listeners = this.eventListeners.get(type);
70
78
  if (!listeners) {
71
79
  listeners = new Set();
@@ -81,6 +89,7 @@ export class WebSocketManager {
81
89
 
82
90
  /** Observe a non-resumable cursor so domain views can perform a full resync. */
83
91
  onConsoleEventRecoveryGap(listener: ConsoleEventRecoveryGapListener): () => void {
92
+ this.assertActive();
84
93
  this.recoveryGapListeners.add(listener);
85
94
  return () => this.recoveryGapListeners.delete(listener);
86
95
  }
@@ -90,28 +99,17 @@ export class WebSocketManager {
90
99
  }
91
100
 
92
101
  connect(): void {
93
- if (this.useRestTransport) {
94
- void this.connectRestSse();
95
- return;
96
- }
102
+ this.assertActive();
97
103
  if (this.state === ConnectionState.CONNECTED || this.state === ConnectionState.CONNECTING) return;
98
- this.setState(ConnectionState.CONNECTING);
99
- try {
100
- this.ws = new WebSocket(this.config.url);
101
- this.attachEventHandlers();
102
- } catch (error) {
103
- this.handleConnectionError(new ConnectionError("Failed to create WebSocket", error as Error));
104
- this.setState(ConnectionState.RECONNECTING);
105
- this.notifyConnection(false);
106
- this.scheduleReconnect();
107
- }
104
+ const generation = ++this.connectionGeneration;
105
+ void this.connectSse(generation);
108
106
  }
109
107
 
110
- private async connectRestSse(): Promise<void> {
108
+ private async connectSse(generation: number): Promise<void> {
111
109
  // Host without http.token allows unauthenticated SSE (authenticateHttp
112
110
  // returns full when TokenRegistry is empty). Still open the stream so
113
111
  // local dev Console receives hmr:reload / message.receive broadcasts.
114
- const token = getStoredToken();
112
+ const token = getToken();
115
113
  this.setState(ConnectionState.CONNECTING);
116
114
  this.sseAbort?.abort();
117
115
  this.sseAbort = new AbortController();
@@ -121,14 +119,19 @@ export class WebSocketManager {
121
119
  };
122
120
  if (token) headers.Authorization = `Bearer ${token}`;
123
121
  await this.recoverEventHistory(this.sseAbort.signal);
122
+ if (!this.isCurrentConnection(generation)) return;
124
123
  const params = new URLSearchParams();
125
124
  if (this.eventRuntimeId) params.set('runtimeId', this.eventRuntimeId);
126
125
  if (this.lastEventId > 0) params.set('after', String(this.lastEventId));
127
126
  const suffix = params.size ? `?${params}` : '';
128
- const res = await fetch(resolveApiUrl(`/api/events${suffix}`), {
127
+ const res = await this.config.fetch(resolveApiUrl(`/api/events${suffix}`), {
129
128
  headers,
130
129
  signal: this.sseAbort.signal,
131
130
  });
131
+ if (!this.isCurrentConnection(generation)) {
132
+ await res.body?.cancel();
133
+ return;
134
+ }
132
135
  if (!res.ok || !res.body) {
133
136
  throw new ConnectionError(`SSE failed: HTTP ${res.status}`);
134
137
  }
@@ -136,28 +139,31 @@ export class WebSocketManager {
136
139
  this.reconnectAttempts = 0;
137
140
  this.notifyConnection(true);
138
141
  this.callbacks.onConnect?.();
139
- void this.pumpSse(res.body);
142
+ void this.pumpSse(res.body, generation);
140
143
  } catch (error) {
144
+ if (!this.isCurrentConnection(generation)) return;
141
145
  if ((error as Error).name === "AbortError") return;
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);
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);
147
150
  this.setState(ConnectionState.RECONNECTING);
148
151
  this.notifyConnection(false);
149
152
  this.scheduleReconnect();
150
153
  }
151
154
  }
152
155
 
153
- private async pumpSse(body: ReadableStream<Uint8Array>): Promise<void> {
156
+ private async pumpSse(
157
+ body: ReadableStream<Uint8Array>,
158
+ generation: number,
159
+ ): Promise<void> {
154
160
  const reader = body.getReader();
155
161
  const decoder = new TextDecoder();
156
162
  let buffer = "";
157
163
  try {
158
164
  while (true) {
159
165
  const { done, value } = await reader.read();
160
- if (done) break;
166
+ if (done || !this.isCurrentConnection(generation)) break;
161
167
  buffer += decoder.decode(value, { stream: true });
162
168
  const parts = buffer.split(/\r?\n\r?\n/u);
163
169
  buffer = parts.pop() ?? "";
@@ -179,9 +185,10 @@ export class WebSocketManager {
179
185
  } catch {
180
186
  /* closed */
181
187
  } finally {
182
- if (this.state === ConnectionState.CONNECTED) {
188
+ if (this.isCurrentConnection(generation) && this.state === ConnectionState.CONNECTED) {
183
189
  this.notifyConnection(false);
184
190
  this.setState(ConnectionState.RECONNECTING);
191
+ this.callbacks.onDisconnect?.();
185
192
  this.scheduleReconnect();
186
193
  }
187
194
  }
@@ -199,7 +206,7 @@ export class WebSocketManager {
199
206
  }
200
207
  for (;;) {
201
208
  const query = { runtimeId, after, limit: 500 } satisfies ConsoleEventHistoryQuery;
202
- const page = await fetchConsoleEventHistory(query, { signal });
209
+ const page = await fetchConsoleEventHistory(query, { signal, fetch: this.config.fetch });
203
210
  if (page.runtimeId !== runtimeId) {
204
211
  runtimeId = page.runtimeId;
205
212
  after = 0;
@@ -232,14 +239,14 @@ export class WebSocketManager {
232
239
  if (event.eventId > 0
233
240
  && event.runtimeId === this.eventRuntimeId
234
241
  && event.eventId <= this.lastEventId) return;
235
- const message = normalizeConsolePushMessage({
242
+ const message: ConsoleTransportMessage = Object.freeze({
236
243
  type: event.type,
237
244
  data: event.data,
238
245
  runtimeId: event.runtimeId,
239
246
  eventId: event.eventId || undefined,
240
247
  timestamp: event.timestamp,
241
248
  delivery: event.delivery,
242
- }) as WebSocketMessage;
249
+ });
243
250
  try {
244
251
  await applyConsoleEvent(message);
245
252
  } catch (error) {
@@ -281,59 +288,27 @@ export class WebSocketManager {
281
288
  }
282
289
 
283
290
  disconnect(): void {
291
+ const wasConnected = this.state === ConnectionState.CONNECTED;
292
+ this.connectionGeneration += 1;
284
293
  this.clearReconnectTimer();
285
- this.clearPendingRequests();
286
294
  this.sseAbort?.abort();
287
295
  this.sseAbort = null;
288
- if (this.ws) {
289
- this.ws.close();
290
- this.ws = null;
291
- }
292
296
  this.setState(ConnectionState.DISCONNECTED);
293
297
  this.notifyConnection(false);
298
+ if (wasConnected) this.callbacks.onDisconnect?.();
294
299
  }
295
300
 
296
301
  send(message: unknown): void {
297
- if (this.useRestTransport) {
298
- void this.sendRequest(message);
299
- return;
300
- }
301
- if (!this.isConnected()) throw new WebSocketError("WebSocket is not connected", "NOT_CONNECTED");
302
- try {
303
- this.ws!.send(JSON.stringify(message));
304
- } catch (error) {
305
- throw new MessageError("Failed to send message", error as Error);
306
- }
302
+ this.assertActive();
303
+ void this.sendRequest(message);
307
304
  }
308
305
 
309
306
  async sendRequest<T = unknown>(message: unknown): Promise<T> {
310
- if (this.useRestTransport) {
311
- return this.sendRestRequest<T>(message);
312
- }
313
- if (!this.isConnected()) throw new WebSocketError("WebSocket is not connected", "NOT_CONNECTED");
314
- return new Promise((resolve, reject) => {
315
- const requestId = ++this.requestId;
316
- const messageWithId = { ...(message as Record<string, unknown>), requestId };
317
- const timer = setTimeout(() => {
318
- this.pendingRequests.delete(requestId);
319
- reject(new RequestTimeoutError(requestId));
320
- }, this.config.requestTimeout);
321
- this.pendingRequests.set(requestId, { resolve: resolve as (v: unknown) => void, reject, timer });
322
- try {
323
- this.ws!.send(JSON.stringify(messageWithId));
324
- } catch (error) {
325
- this.pendingRequests.delete(requestId);
326
- clearTimeout(timer);
327
- reject(new MessageError("Failed to send request", error as Error));
328
- }
329
- });
330
- }
331
-
332
- private async sendRestRequest<T>(message: unknown): Promise<T> {
307
+ this.assertActive();
333
308
  // Host may run without http.token (local smoke). Only require a token when
334
309
  // one is configured client-side; otherwise POST without Authorization and
335
310
  // let the Host accept (TokenRegistry empty → full scope).
336
- const token = getStoredToken();
311
+ const token = getToken();
337
312
  const requestId = ++this.requestId;
338
313
  const body = { ...(message as Record<string, unknown>), requestId };
339
314
  try {
@@ -341,7 +316,7 @@ export class WebSocketManager {
341
316
  "Content-Type": "application/json",
342
317
  };
343
318
  if (token) headers.Authorization = `Bearer ${token}`;
344
- const res = await fetch(resolveApiUrl("/api/console/request"), {
319
+ const res = await this.config.fetch(resolveApiUrl("/api/console/request"), {
345
320
  method: "POST",
346
321
  headers,
347
322
  body: JSON.stringify(body),
@@ -354,32 +329,162 @@ export class WebSocketManager {
354
329
  requestId?: number;
355
330
  };
356
331
  if (!res.ok || json.success === false) {
357
- throw new WebSocketError(json.error ?? `HTTP ${res.status}`, "SERVER_ERROR");
332
+ throw new ConsoleTransportError(json.error ?? `HTTP ${res.status}`, "SERVER_ERROR");
358
333
  }
359
334
  return json.data as T;
360
335
  } catch (error) {
361
- if (error instanceof WebSocketError) throw error;
336
+ if (error instanceof ConsoleTransportError) throw error;
362
337
  throw new MessageError("REST request failed", error as Error);
363
338
  }
364
339
  }
365
340
 
366
341
  isConnected(): boolean {
367
- if (this.useRestTransport) {
368
- return this.state === ConnectionState.CONNECTED;
369
- }
370
- return this.ws?.readyState === WebSocket.OPEN;
342
+ return this.state === ConnectionState.CONNECTED;
371
343
  }
372
344
 
373
345
  getState(): ConnectionState {
374
346
  return this.state;
375
347
  }
376
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
+
377
358
  async getConfig(pluginName: string) {
378
- return this.sendRequest<unknown>({ type: "config:get", pluginName });
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 });
379
484
  }
380
485
  async setConfig(pluginName: string, config: unknown) {
381
486
  return this.sendRequest<{ success?: boolean; reloaded?: boolean; message?: string }>({
382
- type: "config:set",
487
+ type: CONFIG_RPC.SET,
383
488
  pluginName,
384
489
  data: config,
385
490
  });
@@ -388,16 +493,25 @@ export class WebSocketManager {
388
493
  return this.sendRequest<unknown>({ type: "schema:get", pluginName });
389
494
  }
390
495
  async getAllConfigs() {
391
- return this.sendRequest<Record<string, unknown>>({ type: "config:get-all" });
496
+ return this.sendRequest<Record<string, unknown>>({ type: CONFIG_RPC.GET_ALL });
392
497
  }
393
498
  async getAllSchemas() {
394
499
  return this.sendRequest<Record<string, unknown>>({ type: "schema:get-all" });
395
500
  }
396
- async getConfigYaml() {
397
- return this.sendRequest<{ yaml: string; pluginKeys: string[] }>({ type: "config:get-yaml" });
398
- }
399
- async saveConfigYaml(yaml: string) {
400
- return this.sendRequest<{ success: boolean; message?: string }>({ type: "config:save-yaml", yaml });
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
+ });
401
515
  }
402
516
  async getEnvList() {
403
517
  return this.sendRequest<{ files: Array<{ name: string; exists: boolean }> }>({ type: "env:list" });
@@ -409,7 +523,7 @@ export class WebSocketManager {
409
523
  return this.sendRequest<{ success: boolean; message?: string }>({ type: "env:save", filename, content });
410
524
  }
411
525
  async getFileTree() {
412
- return this.sendRequest<{ tree: import("./types").FileTreeNode[] }>({ type: "files:tree" });
526
+ return this.sendRequest<{ tree: import("./types.js").FileTreeNode[] }>({ type: "files:tree" });
413
527
  }
414
528
  async readFile(filePath: string) {
415
529
  return this.sendRequest<{ content: string; size: number }>({ type: "files:read", filePath });
@@ -418,13 +532,13 @@ export class WebSocketManager {
418
532
  return this.sendRequest<{ success: boolean; message?: string }>({ type: "files:save", filePath, content });
419
533
  }
420
534
  async getDbInfo() {
421
- return this.sendRequest<import("./types").DatabaseInfo>({ type: "db:info" });
535
+ return this.sendRequest<import("./types.js").DatabaseInfo>({ type: "db:info" });
422
536
  }
423
537
  async getDbTables() {
424
- return this.sendRequest<{ tables: import("./types").TableInfo[] }>({ type: "db:tables" });
538
+ return this.sendRequest<{ tables: import("./types.js").TableInfo[] }>({ type: "db:tables" });
425
539
  }
426
540
  async dbSelect(table: string, page?: number, pageSize?: number, where?: unknown) {
427
- return this.sendRequest<import("./types").SelectResult>({ type: "db:select", table, page, pageSize, where });
541
+ return this.sendRequest<import("./types.js").SelectResult>({ type: "db:select", table, page, pageSize, where });
428
542
  }
429
543
  async dbInsert(table: string, row: unknown) {
430
544
  return this.sendRequest<{ success: boolean }>({ type: "db:insert", table, row });
@@ -448,53 +562,29 @@ export class WebSocketManager {
448
562
  return this.sendRequest<{ success: boolean }>({ type: "db:kv:delete", table, key });
449
563
  }
450
564
  async kvGetEntries(table: string) {
451
- return this.sendRequest<{ entries: import("./types").KvEntry[] }>({ type: "db:kv:entries", table });
565
+ return this.sendRequest<{ entries: import("./types.js").KvEntry[] }>({ type: "db:kv:entries", table });
452
566
  }
453
567
 
454
- private buildWebSocketUrl(customUrl?: string): string {
455
- if (customUrl) return customUrl;
456
- const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
457
- return `${protocol}//${window.location.host}/server`;
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;
458
581
  }
459
582
 
460
583
  private setState(newState: ConnectionState): void {
461
584
  this.state = newState;
462
585
  }
463
586
 
464
- private attachEventHandlers(): void {
465
- if (!this.ws) return;
466
- this.ws.onopen = () => this.handleConnectionOpen();
467
- this.ws.onmessage = (event) => this.handleMessage(event);
468
- this.ws.onclose = () => this.handleConnectionClose();
469
- this.ws.onerror = (event) =>
470
- this.handleConnectionError(new ConnectionError("WebSocket error", event as unknown as Error));
471
- }
472
-
473
- private handleConnectionOpen(): void {
474
- this.setState(ConnectionState.CONNECTED);
475
- this.reconnectAttempts = 0;
476
- this.notifyConnection(true);
477
- this.callbacks.onConnect?.();
478
- }
479
-
480
- private handleMessage(event: MessageEvent): void {
481
- try {
482
- const message = normalizeConsolePushMessage(
483
- JSON.parse(event.data) as WebSocketMessage,
484
- ) as WebSocketMessage;
485
- if (message.requestId && this.pendingRequests.has(message.requestId)) {
486
- this.handleRequestResponse(message);
487
- return;
488
- }
489
- void applyConsoleEvent(message);
490
- this.handleBroadcast(message);
491
- this.callbacks.onMessage?.(message);
492
- } catch (error) {
493
- console.error("[WebSocket] Message parsing error:", error);
494
- }
495
- }
496
-
497
- private handleBroadcast(message: WebSocketMessage): void {
587
+ private handleBroadcast(message: ConsoleTransportMessage): void {
498
588
  const t = message.type;
499
589
  if (
500
590
  t === SIDE_EVENT_PUSH.REQUEST_RECEIVE
@@ -518,37 +608,6 @@ export class WebSocketManager {
518
608
  }
519
609
  }
520
610
 
521
- private handleRequestResponse(message: WebSocketMessage): void {
522
- const { requestId } = message;
523
- const pending = this.pendingRequests.get(requestId!);
524
- if (!pending) return;
525
- this.pendingRequests.delete(requestId!);
526
- clearTimeout(pending.timer);
527
- if (message.error) {
528
- pending.reject(new WebSocketError(message.error, "SERVER_ERROR"));
529
- } else {
530
- pending.resolve(message.data);
531
- }
532
- }
533
-
534
- private handleConnectionClose(): void {
535
- this.ws = null;
536
- this.notifyConnection(false);
537
- if (this.state === ConnectionState.DISCONNECTED) return;
538
- this.setState(ConnectionState.RECONNECTING);
539
- this.callbacks.onDisconnect?.();
540
- this.scheduleReconnect();
541
- }
542
-
543
- private handleConnectionError(error: Error): void {
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.
548
- console.error("[Console transport] Connection error:", error);
549
- this.callbacks.onError?.(error as unknown as Event);
550
- }
551
-
552
611
  private scheduleReconnect(): void {
553
612
  if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
554
613
  this.setState(ConnectionState.ERROR);
@@ -579,12 +638,12 @@ export class WebSocketManager {
579
638
  }
580
639
  }
581
640
 
582
- private clearPendingRequests(): void {
583
- for (const [, { reject, timer }] of this.pendingRequests) {
584
- clearTimeout(timer);
585
- reject(new WebSocketError("Connection closed", "CONNECTION_CLOSED"));
586
- }
587
- this.pendingRequests.clear();
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;
588
647
  }
589
648
  }
590
649