@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.
- package/CHANGELOG.md +6 -0
- package/dist/assets/ChannelsList-BgJbR6E9.js +1 -0
- package/dist/assets/{ChatPage-CMthudUt.js → ChatPage-Bv9UJPse.js} +24 -24
- package/dist/assets/{DocBrowser-BOvBC_5q.js → DocBrowser-Dw9BGO1m.js} +1 -1
- package/dist/assets/{LogoBadge-BUvLZbji.js → LogoBadge-CLc2B6st.js} +1 -1
- package/dist/assets/{MarketplacePage-CcbfvtGX.js → MarketplacePage-ChqCNL7k.js} +1 -1
- package/dist/assets/{McpMarketplacePage-D56yvyWI.js → McpMarketplacePage-B3PF-7ED.js} +1 -1
- package/dist/assets/ModelConfig-Dqz_NOow.js +1 -0
- package/dist/assets/{ProvidersList-Bd4n7muZ.js → ProvidersList-D2WaZShJ.js} +1 -1
- package/dist/assets/{RemoteAccessPage-Be8jduPM.js → RemoteAccessPage-D_l9irp4.js} +1 -1
- package/dist/assets/{RuntimeConfig-D8DYogZ1.js → RuntimeConfig-TDxQLuGy.js} +1 -1
- package/dist/assets/{SearchConfig-BtiGCmXR.js → SearchConfig-gba64nGn.js} +1 -1
- package/dist/assets/{SecretsConfig-fwAjbwlq.js → SecretsConfig-DpL8wgly.js} +1 -1
- package/dist/assets/{SessionsConfig-Y7_TDSk2.js → SessionsConfig-CAODVTNW.js} +1 -1
- package/dist/assets/{chat-message-Cwq8nW0e.js → chat-message-CSG50nNb.js} +1 -1
- package/dist/assets/index-DaEflNCE.js +8 -0
- package/dist/assets/{label-C0dJBNgU.js → label-3T28q3PJ.js} +1 -1
- package/dist/assets/{page-layout-4_789zOC.js → page-layout-BrXOQeua.js} +1 -1
- package/dist/assets/{popover-CWmq2f6H.js → popover-BrBJjElY.js} +1 -1
- package/dist/assets/{security-config-CZeVwEwq.js → security-config-oGAhN4Zf.js} +1 -1
- package/dist/assets/{skeleton-kjkadEki.js → skeleton-CIPQUKo2.js} +1 -1
- package/dist/assets/{status-dot-C7cVa53V.js → status-dot-QL3hmT1d.js} +1 -1
- package/dist/assets/{switch-C6zdGbY0.js → switch-Dbt2kUg2.js} +1 -1
- package/dist/assets/{tabs-custom-BQj0Z-ZC.js → tabs-custom-y5hdkzXk.js} +1 -1
- package/dist/assets/{useConfirmDialog-yX-ZMNf9.js → useConfirmDialog-B4zwBVbl.js} +1 -1
- package/dist/index.html +1 -1
- package/package.json +4 -4
- package/src/App.tsx +2 -2
- package/src/api/client.ts +5 -5
- package/src/api/config.ts +3 -2
- package/src/components/chat/chat-stream/transport.ts +67 -16
- package/src/components/config/ModelConfig.test.tsx +78 -0
- package/src/components/config/ModelConfig.tsx +4 -1
- package/src/hooks/use-realtime-query-bridge.ts +108 -0
- package/src/transport/app-client.ts +107 -0
- package/src/transport/index.ts +9 -0
- package/src/transport/local.transport.ts +286 -0
- package/src/transport/remote.transport.ts +398 -0
- package/src/transport/transport.types.ts +41 -0
- package/dist/assets/ChannelsList-a063_8pv.js +0 -1
- package/dist/assets/ModelConfig-D5AuTffd.js +0 -1
- package/dist/assets/index-C6dwNe7e.js +0 -8
- package/src/api/websocket.ts +0 -79
- 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
|
+
}
|