@nextclaw/ui 0.9.9 → 0.9.10

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 (44) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/assets/ChannelsList-BgJbR6E9.js +1 -0
  3. package/dist/assets/{ChatPage-CMthudUt.js → ChatPage-Bv9UJPse.js} +24 -24
  4. package/dist/assets/{DocBrowser-BOvBC_5q.js → DocBrowser-Dw9BGO1m.js} +1 -1
  5. package/dist/assets/{LogoBadge-BUvLZbji.js → LogoBadge-CLc2B6st.js} +1 -1
  6. package/dist/assets/{MarketplacePage-CcbfvtGX.js → MarketplacePage-ChqCNL7k.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-D56yvyWI.js → McpMarketplacePage-B3PF-7ED.js} +1 -1
  8. package/dist/assets/ModelConfig-Dqz_NOow.js +1 -0
  9. package/dist/assets/{ProvidersList-Bd4n7muZ.js → ProvidersList-D2WaZShJ.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-Be8jduPM.js → RemoteAccessPage-D_l9irp4.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-D8DYogZ1.js → RuntimeConfig-TDxQLuGy.js} +1 -1
  12. package/dist/assets/{SearchConfig-BtiGCmXR.js → SearchConfig-gba64nGn.js} +1 -1
  13. package/dist/assets/{SecretsConfig-fwAjbwlq.js → SecretsConfig-DpL8wgly.js} +1 -1
  14. package/dist/assets/{SessionsConfig-Y7_TDSk2.js → SessionsConfig-CAODVTNW.js} +1 -1
  15. package/dist/assets/{chat-message-Cwq8nW0e.js → chat-message-CSG50nNb.js} +1 -1
  16. package/dist/assets/index-DaEflNCE.js +8 -0
  17. package/dist/assets/{label-C0dJBNgU.js → label-3T28q3PJ.js} +1 -1
  18. package/dist/assets/{page-layout-4_789zOC.js → page-layout-BrXOQeua.js} +1 -1
  19. package/dist/assets/{popover-CWmq2f6H.js → popover-BrBJjElY.js} +1 -1
  20. package/dist/assets/{security-config-CZeVwEwq.js → security-config-oGAhN4Zf.js} +1 -1
  21. package/dist/assets/{skeleton-kjkadEki.js → skeleton-CIPQUKo2.js} +1 -1
  22. package/dist/assets/{status-dot-C7cVa53V.js → status-dot-QL3hmT1d.js} +1 -1
  23. package/dist/assets/{switch-C6zdGbY0.js → switch-Dbt2kUg2.js} +1 -1
  24. package/dist/assets/{tabs-custom-BQj0Z-ZC.js → tabs-custom-y5hdkzXk.js} +1 -1
  25. package/dist/assets/{useConfirmDialog-yX-ZMNf9.js → useConfirmDialog-B4zwBVbl.js} +1 -1
  26. package/dist/index.html +1 -1
  27. package/package.json +4 -4
  28. package/src/App.tsx +2 -2
  29. package/src/api/client.ts +5 -5
  30. package/src/api/config.ts +3 -2
  31. package/src/components/chat/chat-stream/transport.ts +67 -16
  32. package/src/components/config/ModelConfig.test.tsx +78 -0
  33. package/src/components/config/ModelConfig.tsx +4 -1
  34. package/src/hooks/use-realtime-query-bridge.ts +108 -0
  35. package/src/transport/app-client.ts +107 -0
  36. package/src/transport/index.ts +9 -0
  37. package/src/transport/local.transport.ts +286 -0
  38. package/src/transport/remote.transport.ts +398 -0
  39. package/src/transport/transport.types.ts +41 -0
  40. package/dist/assets/ChannelsList-a063_8pv.js +0 -1
  41. package/dist/assets/ModelConfig-D5AuTffd.js +0 -1
  42. package/dist/assets/index-C6dwNe7e.js +0 -8
  43. package/src/api/websocket.ts +0 -79
  44. package/src/hooks/useWebSocket.ts +0 -190
@@ -0,0 +1,286 @@
1
+ import { API_BASE, requestApiResponse } from '@/api/client';
2
+ import type { ApiResponse } from '@/api/types';
3
+ import type { AppEvent, AppTransport, RequestInput, StreamEvent, StreamInput, StreamSession } from './transport.types';
4
+
5
+ type EventHandler = (event: AppEvent) => void;
6
+
7
+ function toWebSocketUrl(base: string, path: string): string {
8
+ const normalizedBase = base.replace(/\/$/, '');
9
+ try {
10
+ const resolved = new URL(normalizedBase, window.location.origin);
11
+ const protocol =
12
+ resolved.protocol === 'https:'
13
+ ? 'wss:'
14
+ : resolved.protocol === 'http:'
15
+ ? 'ws:'
16
+ : resolved.protocol;
17
+ return `${protocol}//${resolved.host}${path}`;
18
+ } catch {
19
+ if (normalizedBase.startsWith('wss://') || normalizedBase.startsWith('ws://')) {
20
+ return `${normalizedBase}${path}`;
21
+ }
22
+ if (normalizedBase.startsWith('https://')) {
23
+ return `${normalizedBase.replace(/^https:/, 'wss:')}${path}`;
24
+ }
25
+ if (normalizedBase.startsWith('http://')) {
26
+ return `${normalizedBase.replace(/^http:/, 'ws:')}${path}`;
27
+ }
28
+ return `${normalizedBase}${path}`;
29
+ }
30
+ }
31
+
32
+ function createTransportError(response: ApiResponse<unknown>, fallback: string): Error {
33
+ if (!response.ok) {
34
+ return new Error(response.error.message);
35
+ }
36
+ return new Error(fallback);
37
+ }
38
+
39
+ function parseSseFrame(frame: string): StreamEvent | null {
40
+ const lines = frame.split('\n');
41
+ let name = '';
42
+ const dataLines: string[] = [];
43
+ for (const raw of lines) {
44
+ const line = raw.trimEnd();
45
+ if (!line || line.startsWith(':')) {
46
+ continue;
47
+ }
48
+ if (line.startsWith('event:')) {
49
+ name = line.slice(6).trim();
50
+ continue;
51
+ }
52
+ if (line.startsWith('data:')) {
53
+ dataLines.push(line.slice(5).trimStart());
54
+ }
55
+ }
56
+ if (!name) {
57
+ return null;
58
+ }
59
+
60
+ let payload: unknown = undefined;
61
+ const data = dataLines.join('\n');
62
+ if (data) {
63
+ try {
64
+ payload = JSON.parse(data);
65
+ } catch {
66
+ payload = data;
67
+ }
68
+ }
69
+
70
+ return {
71
+ name,
72
+ payload
73
+ };
74
+ }
75
+
76
+ class LocalRealtimeGateway {
77
+ private socket: WebSocket | null = null;
78
+ private reconnectTimer: number | null = null;
79
+ private manualClose = false;
80
+ private subscribers = new Set<EventHandler>();
81
+
82
+ constructor(private readonly wsUrl: string) {}
83
+
84
+ subscribe(handler: EventHandler): () => void {
85
+ this.subscribers.add(handler);
86
+ if (this.subscribers.size === 1) {
87
+ this.connect();
88
+ } else if (this.socket?.readyState === WebSocket.OPEN) {
89
+ handler({ type: 'connection.open', payload: {} });
90
+ }
91
+
92
+ return () => {
93
+ this.subscribers.delete(handler);
94
+ if (this.subscribers.size === 0) {
95
+ this.disconnect();
96
+ }
97
+ };
98
+ }
99
+
100
+ private emit(event: AppEvent): void {
101
+ for (const subscriber of this.subscribers) {
102
+ subscriber(event);
103
+ }
104
+ }
105
+
106
+ private connect(): void {
107
+ if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) {
108
+ return;
109
+ }
110
+ this.manualClose = false;
111
+ const socket = new WebSocket(this.wsUrl);
112
+ this.socket = socket;
113
+
114
+ socket.onopen = () => {
115
+ this.emit({ type: 'connection.open', payload: {} });
116
+ };
117
+
118
+ socket.onmessage = (event) => {
119
+ try {
120
+ const data = JSON.parse(String(event.data ?? '')) as AppEvent;
121
+ this.emit(data);
122
+ } catch (error) {
123
+ console.error('Failed to parse websocket message:', error);
124
+ }
125
+ };
126
+
127
+ socket.onerror = () => {
128
+ this.emit({ type: 'connection.error', payload: { message: 'websocket error' } });
129
+ };
130
+
131
+ socket.onclose = () => {
132
+ this.emit({ type: 'connection.close', payload: {} });
133
+ this.socket = null;
134
+ if (!this.manualClose && this.subscribers.size > 0) {
135
+ this.scheduleReconnect();
136
+ }
137
+ };
138
+ }
139
+
140
+ private scheduleReconnect(): void {
141
+ if (this.reconnectTimer !== null) {
142
+ return;
143
+ }
144
+ this.reconnectTimer = window.setTimeout(() => {
145
+ this.reconnectTimer = null;
146
+ this.connect();
147
+ }, 3_000);
148
+ }
149
+
150
+ private disconnect(): void {
151
+ this.manualClose = true;
152
+ if (this.reconnectTimer !== null) {
153
+ window.clearTimeout(this.reconnectTimer);
154
+ this.reconnectTimer = null;
155
+ }
156
+ this.socket?.close();
157
+ this.socket = null;
158
+ }
159
+ }
160
+
161
+ export class LocalAppTransport implements AppTransport {
162
+ private readonly realtimeGateway: LocalRealtimeGateway;
163
+
164
+ constructor(
165
+ private readonly options: {
166
+ apiBase?: string;
167
+ wsPath?: string;
168
+ } = {}
169
+ ) {
170
+ const apiBase = options.apiBase ?? API_BASE;
171
+ this.realtimeGateway = new LocalRealtimeGateway(toWebSocketUrl(apiBase, options.wsPath ?? '/ws'));
172
+ }
173
+
174
+ async request<T>(input: RequestInput): Promise<T> {
175
+ const response = await requestApiResponse<T>(input.path, {
176
+ method: input.method,
177
+ ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {})
178
+ });
179
+ if (!response.ok) {
180
+ throw createTransportError(response, `Request failed for ${input.method} ${input.path}`);
181
+ }
182
+ return response.data;
183
+ }
184
+
185
+ openStream<TFinal = unknown>(input: StreamInput): StreamSession<TFinal> {
186
+ const controller = new AbortController();
187
+ const abort = () => controller.abort();
188
+ if (input.signal) {
189
+ if (input.signal.aborted) {
190
+ abort();
191
+ } else {
192
+ input.signal.addEventListener('abort', abort, { once: true });
193
+ }
194
+ }
195
+
196
+ const finished = (async () => {
197
+ const response = await fetch(`${API_BASE}${input.path}`, {
198
+ method: input.method,
199
+ credentials: 'include',
200
+ headers: {
201
+ 'Content-Type': 'application/json',
202
+ Accept: 'text/event-stream'
203
+ },
204
+ ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
205
+ signal: controller.signal
206
+ });
207
+
208
+ if (!response.ok) {
209
+ const text = await response.text();
210
+ throw new Error(text.trim() || `HTTP ${response.status}`);
211
+ }
212
+
213
+ const reader = response.body?.getReader();
214
+ if (!reader) {
215
+ throw new Error('SSE response body unavailable');
216
+ }
217
+
218
+ const decoder = new TextDecoder();
219
+ let buffer = '';
220
+ let finalResult: unknown;
221
+
222
+ try {
223
+ while (true) {
224
+ const { value, done } = await reader.read();
225
+ if (done) {
226
+ break;
227
+ }
228
+ buffer += decoder.decode(value, { stream: true });
229
+ let boundary = buffer.indexOf('\n\n');
230
+ while (boundary !== -1) {
231
+ const frame = parseSseFrame(buffer.slice(0, boundary));
232
+ buffer = buffer.slice(boundary + 2);
233
+ if (frame) {
234
+ if (frame.name === 'final') {
235
+ finalResult = frame.payload;
236
+ } else if (frame.name === 'error') {
237
+ const errorPayload = frame.payload as { message?: string } | string | undefined;
238
+ const message = typeof errorPayload === 'string'
239
+ ? errorPayload
240
+ : errorPayload?.message ?? 'chat stream failed';
241
+ throw new Error(message);
242
+ } else {
243
+ input.onEvent(frame);
244
+ }
245
+ }
246
+ boundary = buffer.indexOf('\n\n');
247
+ }
248
+ }
249
+
250
+ if (buffer.trim()) {
251
+ const frame = parseSseFrame(buffer);
252
+ if (frame) {
253
+ if (frame.name === 'final') {
254
+ finalResult = frame.payload;
255
+ } else if (frame.name === 'error') {
256
+ const errorPayload = frame.payload as { message?: string } | string | undefined;
257
+ const message = typeof errorPayload === 'string'
258
+ ? errorPayload
259
+ : errorPayload?.message ?? 'chat stream failed';
260
+ throw new Error(message);
261
+ } else {
262
+ input.onEvent(frame);
263
+ }
264
+ }
265
+ }
266
+ } finally {
267
+ reader.releaseLock();
268
+ input.signal?.removeEventListener('abort', abort);
269
+ }
270
+
271
+ if (finalResult === undefined) {
272
+ throw new Error('stream ended without final event');
273
+ }
274
+ return finalResult as TFinal;
275
+ })();
276
+
277
+ return {
278
+ finished,
279
+ cancel: () => controller.abort()
280
+ };
281
+ }
282
+
283
+ subscribe(handler: (event: AppEvent) => void): () => void {
284
+ return this.realtimeGateway.subscribe(handler);
285
+ }
286
+ }
@@ -0,0 +1,398 @@
1
+ import { API_BASE } from '@/api/client';
2
+ import type { ApiError } from '@/api/types';
3
+ import type { AppEvent, AppTransport, RemoteRuntimeInfo, RequestInput, StreamInput, StreamSession } from './transport.types';
4
+
5
+ type RemoteTarget = {
6
+ method: string;
7
+ path: string;
8
+ body?: unknown;
9
+ };
10
+
11
+ type RemoteBrowserFrame =
12
+ | { type: 'connection.ready'; connectionId: string; protocolVersion: 1 }
13
+ | { type: 'response'; id: string; status: number; body?: unknown }
14
+ | { type: 'request.error'; id: string; message: string; code?: string }
15
+ | { type: 'stream.event'; streamId: string; event: string; payload?: unknown }
16
+ | { type: 'stream.end'; streamId: string; result?: unknown }
17
+ | { type: 'stream.error'; streamId: string; message: string; code?: string }
18
+ | { type: 'event'; event: AppEvent }
19
+ | { type: 'connection.error'; message: string; code?: string };
20
+
21
+ type RemoteBrowserCommand =
22
+ | { type: 'request'; id: string; target: RemoteTarget }
23
+ | { type: 'stream.open'; streamId: string; target: RemoteTarget }
24
+ | { type: 'stream.cancel'; streamId: string };
25
+
26
+ type PendingRequest = {
27
+ resolve: (value: unknown) => void;
28
+ reject: (error: Error) => void;
29
+ };
30
+
31
+ type PendingStream = {
32
+ onEvent: StreamInput['onEvent'];
33
+ resolve: (value: unknown) => void;
34
+ reject: (error: Error) => void;
35
+ };
36
+
37
+ function createWsUrl(apiBase: string, wsPath: string): string {
38
+ const normalizedBase = apiBase.replace(/\/$/, '');
39
+ try {
40
+ const resolved = new URL(normalizedBase, window.location.origin);
41
+ const protocol =
42
+ resolved.protocol === 'https:'
43
+ ? 'wss:'
44
+ : resolved.protocol === 'http:'
45
+ ? 'ws:'
46
+ : resolved.protocol;
47
+ return `${protocol}//${resolved.host}${wsPath}`;
48
+ } catch {
49
+ if (normalizedBase.startsWith('wss://') || normalizedBase.startsWith('ws://')) {
50
+ return `${normalizedBase}${wsPath}`;
51
+ }
52
+ if (normalizedBase.startsWith('https://')) {
53
+ return `${normalizedBase.replace(/^https:/, 'wss:')}${wsPath}`;
54
+ }
55
+ if (normalizedBase.startsWith('http://')) {
56
+ return `${normalizedBase.replace(/^http:/, 'ws:')}${wsPath}`;
57
+ }
58
+ return `${normalizedBase}${wsPath}`;
59
+ }
60
+ }
61
+
62
+ function normalizeApiError(body: unknown, status: number, fallback: string): Error {
63
+ if (typeof body === 'object' && body && 'ok' in body) {
64
+ const typed = body as { ok?: boolean; error?: ApiError; data?: unknown };
65
+ if (typed.ok === false && typed.error?.message) {
66
+ return new Error(typed.error.message);
67
+ }
68
+ if (typed.ok === true) {
69
+ return new Error(fallback);
70
+ }
71
+ }
72
+ if (typeof body === 'string' && body.trim()) {
73
+ return new Error(body.trim());
74
+ }
75
+ return new Error(`${fallback} (${status})`);
76
+ }
77
+
78
+ function unwrapApiBody<T>(body: unknown): T {
79
+ if (typeof body === 'object' && body && 'ok' in body) {
80
+ const typed = body as { ok?: boolean; error?: ApiError; data?: T };
81
+ if (typed.ok === false) {
82
+ throw new Error(typed.error?.message ?? 'Remote request failed.');
83
+ }
84
+ if (typed.ok === true) {
85
+ return typed.data as T;
86
+ }
87
+ }
88
+ return body as T;
89
+ }
90
+
91
+ function createId(prefix: string): string {
92
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
93
+ }
94
+
95
+ export class RemoteSessionMultiplexTransport implements AppTransport {
96
+ private socket: WebSocket | null = null;
97
+ private connectPromise: Promise<void> | null = null;
98
+ private connectTimeoutId: number | null = null;
99
+ private reconnectTimer: number | null = null;
100
+ private manualClose = false;
101
+ private subscribers = new Set<(event: AppEvent) => void>();
102
+ private pendingRequests = new Map<string, PendingRequest>();
103
+ private pendingStreams = new Map<string, PendingStream>();
104
+
105
+ constructor(
106
+ private readonly runtime: RemoteRuntimeInfo,
107
+ private readonly apiBase: string = API_BASE
108
+ ) {}
109
+
110
+ async request<T>(input: RequestInput): Promise<T> {
111
+ await this.ensureSocket();
112
+ const id = createId('req');
113
+ return await new Promise<T>((resolve, reject) => {
114
+ this.pendingRequests.set(id, {
115
+ resolve: (value) => resolve(value as T),
116
+ reject
117
+ });
118
+ this.send({
119
+ type: 'request',
120
+ id,
121
+ target: {
122
+ method: input.method,
123
+ path: input.path,
124
+ ...(input.body !== undefined ? { body: input.body } : {})
125
+ }
126
+ });
127
+ });
128
+ }
129
+
130
+ openStream<TFinal = unknown>(input: StreamInput): StreamSession<TFinal> {
131
+ const streamId = createId('stream');
132
+ let settled = false;
133
+ const rejectEarly = (error: Error) => {
134
+ if (!settled) {
135
+ settled = true;
136
+ reject(error);
137
+ }
138
+ };
139
+ let resolve!: (value: TFinal) => void;
140
+ let reject!: (error: Error) => void;
141
+ const finished = new Promise<TFinal>((innerResolve, innerReject) => {
142
+ resolve = innerResolve;
143
+ reject = innerReject;
144
+ });
145
+
146
+ const cancel = () => {
147
+ this.pendingStreams.delete(streamId);
148
+ if (settled) {
149
+ return;
150
+ }
151
+ settled = true;
152
+ this.send({ type: 'stream.cancel', streamId });
153
+ reject(new Error('stream cancelled'));
154
+ };
155
+
156
+ this.pendingStreams.set(streamId, {
157
+ onEvent: input.onEvent,
158
+ resolve: (value) => {
159
+ if (settled) {
160
+ return;
161
+ }
162
+ settled = true;
163
+ resolve(value as TFinal);
164
+ },
165
+ reject: (error) => {
166
+ if (settled) {
167
+ return;
168
+ }
169
+ settled = true;
170
+ reject(error);
171
+ }
172
+ });
173
+
174
+ const abort = () => cancel();
175
+ if (input.signal) {
176
+ if (input.signal.aborted) {
177
+ cancel();
178
+ } else {
179
+ input.signal.addEventListener('abort', abort, { once: true });
180
+ }
181
+ }
182
+
183
+ void this.ensureSocket()
184
+ .then(() => {
185
+ if (settled) {
186
+ return;
187
+ }
188
+ this.send({
189
+ type: 'stream.open',
190
+ streamId,
191
+ target: {
192
+ method: input.method,
193
+ path: input.path,
194
+ ...(input.body !== undefined ? { body: input.body } : {})
195
+ }
196
+ });
197
+ })
198
+ .catch((error) => {
199
+ this.pendingStreams.delete(streamId);
200
+ rejectEarly(error instanceof Error ? error : new Error(String(error)));
201
+ });
202
+
203
+ return {
204
+ finished: finished.finally(() => {
205
+ input.signal?.removeEventListener('abort', abort);
206
+ this.pendingStreams.delete(streamId);
207
+ }),
208
+ cancel
209
+ };
210
+ }
211
+
212
+ subscribe(handler: (event: AppEvent) => void): () => void {
213
+ this.subscribers.add(handler);
214
+ void this.ensureSocket().catch((error) => {
215
+ handler({
216
+ type: 'connection.error',
217
+ payload: { message: error instanceof Error ? error.message : String(error) }
218
+ });
219
+ });
220
+ return () => {
221
+ this.subscribers.delete(handler);
222
+ };
223
+ }
224
+
225
+ private emit(event: AppEvent): void {
226
+ for (const subscriber of this.subscribers) {
227
+ subscriber(event);
228
+ }
229
+ }
230
+
231
+ private send(frame: RemoteBrowserCommand): void {
232
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
233
+ throw new Error('Remote transport websocket is not connected.');
234
+ }
235
+ this.socket.send(JSON.stringify(frame));
236
+ }
237
+
238
+ private async ensureSocket(): Promise<void> {
239
+ if (this.socket?.readyState === WebSocket.OPEN && this.connectPromise === null) {
240
+ return;
241
+ }
242
+ if (this.connectPromise) {
243
+ return await this.connectPromise;
244
+ }
245
+
246
+ const wsUrl = createWsUrl(this.apiBase, this.runtime.wsPath);
247
+ this.manualClose = false;
248
+ this.connectPromise = new Promise<void>((innerResolve, innerReject) => {
249
+ const socket = new WebSocket(wsUrl);
250
+ this.socket = socket;
251
+ let connectionOpened = false;
252
+
253
+ const clearConnectTimeout = () => {
254
+ if (this.connectTimeoutId !== null) {
255
+ window.clearTimeout(this.connectTimeoutId);
256
+ this.connectTimeoutId = null;
257
+ }
258
+ };
259
+
260
+ this.connectTimeoutId = window.setTimeout(() => {
261
+ this.connectTimeoutId = null;
262
+ innerReject(new Error('Timed out waiting for remote transport websocket.'));
263
+ socket.close();
264
+ }, 8_000);
265
+
266
+ socket.onopen = () => {
267
+ connectionOpened = true;
268
+ clearConnectTimeout();
269
+ this.connectPromise = null;
270
+ innerResolve();
271
+ this.emit({ type: 'connection.open', payload: {} });
272
+ };
273
+
274
+ socket.onmessage = (event) => {
275
+ try {
276
+ const frame = JSON.parse(String(event.data ?? '')) as RemoteBrowserFrame;
277
+ if (frame.type === 'connection.ready') {
278
+ return;
279
+ }
280
+ this.handleFrame(frame);
281
+ } catch (error) {
282
+ console.error('Failed to parse remote websocket frame:', error);
283
+ }
284
+ };
285
+
286
+ socket.onerror = () => {
287
+ this.emit({ type: 'connection.error', payload: { message: 'remote websocket error' } });
288
+ };
289
+
290
+ socket.onclose = () => {
291
+ clearConnectTimeout();
292
+ const wasConnecting = this.connectPromise !== null;
293
+ this.socket = null;
294
+ this.connectPromise = null;
295
+ this.failPendingWork(new Error('Remote transport connection closed.'));
296
+ this.emit({ type: 'connection.close', payload: {} });
297
+ if (wasConnecting && !connectionOpened) {
298
+ innerReject(new Error('Remote transport connection closed before ready.'));
299
+ }
300
+ if (!this.manualClose && this.subscribers.size > 0) {
301
+ this.scheduleReconnect();
302
+ }
303
+ };
304
+ });
305
+
306
+ const connectPromise = this.connectPromise;
307
+ return await connectPromise;
308
+ }
309
+
310
+ private scheduleReconnect(): void {
311
+ if (this.reconnectTimer !== null) {
312
+ return;
313
+ }
314
+ this.reconnectTimer = window.setTimeout(() => {
315
+ this.reconnectTimer = null;
316
+ void this.ensureSocket().catch(() => undefined);
317
+ }, 3_000);
318
+ }
319
+
320
+ private failPendingWork(error: Error): void {
321
+ for (const pending of this.pendingRequests.values()) {
322
+ pending.reject(error);
323
+ }
324
+ this.pendingRequests.clear();
325
+
326
+ for (const pending of this.pendingStreams.values()) {
327
+ pending.reject(error);
328
+ }
329
+ this.pendingStreams.clear();
330
+ }
331
+
332
+ private handleFrame(frame: RemoteBrowserFrame): void {
333
+ if (frame.type === 'response') {
334
+ const pending = this.pendingRequests.get(frame.id);
335
+ if (!pending) {
336
+ return;
337
+ }
338
+ this.pendingRequests.delete(frame.id);
339
+ if (frame.status >= 400) {
340
+ pending.reject(normalizeApiError(frame.body, frame.status, 'Remote request failed.'));
341
+ return;
342
+ }
343
+ try {
344
+ pending.resolve(unwrapApiBody(frame.body));
345
+ } catch (error) {
346
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
347
+ }
348
+ return;
349
+ }
350
+
351
+ if (frame.type === 'request.error') {
352
+ const pending = this.pendingRequests.get(frame.id);
353
+ if (!pending) {
354
+ return;
355
+ }
356
+ this.pendingRequests.delete(frame.id);
357
+ pending.reject(new Error(frame.message));
358
+ return;
359
+ }
360
+
361
+ if (frame.type === 'stream.event') {
362
+ this.pendingStreams.get(frame.streamId)?.onEvent({
363
+ name: frame.event,
364
+ payload: frame.payload
365
+ });
366
+ return;
367
+ }
368
+
369
+ if (frame.type === 'stream.end') {
370
+ const pending = this.pendingStreams.get(frame.streamId);
371
+ if (!pending) {
372
+ return;
373
+ }
374
+ this.pendingStreams.delete(frame.streamId);
375
+ pending.resolve(frame.result);
376
+ return;
377
+ }
378
+
379
+ if (frame.type === 'stream.error') {
380
+ const pending = this.pendingStreams.get(frame.streamId);
381
+ if (!pending) {
382
+ return;
383
+ }
384
+ this.pendingStreams.delete(frame.streamId);
385
+ pending.reject(new Error(frame.message));
386
+ return;
387
+ }
388
+
389
+ if (frame.type === 'event') {
390
+ this.emit(frame.event);
391
+ return;
392
+ }
393
+
394
+ if (frame.type === 'connection.error') {
395
+ this.emit({ type: 'connection.error', payload: { message: frame.message } });
396
+ }
397
+ }
398
+ }