@flrande/browserctl 0.1.0-dev.7.1
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/LICENSE +21 -0
- package/README-CN.md +66 -0
- package/README.md +66 -0
- package/apps/browserctl/src/commands/a11y-snapshot.ts +20 -0
- package/apps/browserctl/src/commands/act.ts +20 -0
- package/apps/browserctl/src/commands/common.test.ts +87 -0
- package/apps/browserctl/src/commands/common.ts +191 -0
- package/apps/browserctl/src/commands/console-list.ts +20 -0
- package/apps/browserctl/src/commands/cookie-clear.ts +18 -0
- package/apps/browserctl/src/commands/cookie-get.ts +18 -0
- package/apps/browserctl/src/commands/cookie-set.ts +22 -0
- package/apps/browserctl/src/commands/dialog-arm.ts +20 -0
- package/apps/browserctl/src/commands/dom-query-all.ts +18 -0
- package/apps/browserctl/src/commands/dom-query.ts +18 -0
- package/apps/browserctl/src/commands/download-trigger.ts +22 -0
- package/apps/browserctl/src/commands/download-wait.test.ts +67 -0
- package/apps/browserctl/src/commands/download-wait.ts +27 -0
- package/apps/browserctl/src/commands/element-screenshot.ts +20 -0
- package/apps/browserctl/src/commands/frame-list.ts +16 -0
- package/apps/browserctl/src/commands/frame-snapshot.ts +18 -0
- package/apps/browserctl/src/commands/network-wait-for.ts +100 -0
- package/apps/browserctl/src/commands/profile-list.ts +16 -0
- package/apps/browserctl/src/commands/profile-use.ts +18 -0
- package/apps/browserctl/src/commands/response-body.ts +24 -0
- package/apps/browserctl/src/commands/screenshot.ts +16 -0
- package/apps/browserctl/src/commands/snapshot.ts +16 -0
- package/apps/browserctl/src/commands/status.ts +10 -0
- package/apps/browserctl/src/commands/storage-get.ts +20 -0
- package/apps/browserctl/src/commands/storage-set.ts +22 -0
- package/apps/browserctl/src/commands/tab-close.ts +20 -0
- package/apps/browserctl/src/commands/tab-focus.ts +20 -0
- package/apps/browserctl/src/commands/tab-open.ts +19 -0
- package/apps/browserctl/src/commands/tabs.ts +13 -0
- package/apps/browserctl/src/commands/upload-arm.ts +26 -0
- package/apps/browserctl/src/daemon-client.test.ts +253 -0
- package/apps/browserctl/src/daemon-client.ts +632 -0
- package/apps/browserctl/src/e2e.test.ts +99 -0
- package/apps/browserctl/src/main.test.ts +215 -0
- package/apps/browserctl/src/main.ts +372 -0
- package/apps/browserctl/src/smoke.test.ts +16 -0
- package/apps/browserctl/src/smoke.ts +5 -0
- package/apps/browserd/src/bootstrap.ts +432 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.test.ts +275 -0
- package/apps/browserd/src/chrome-relay-extension-bridge.ts +506 -0
- package/apps/browserd/src/container.ts +1531 -0
- package/apps/browserd/src/main.test.ts +864 -0
- package/apps/browserd/src/main.ts +7 -0
- package/bin/browserctl.cjs +21 -0
- package/bin/browserd.cjs +21 -0
- package/extensions/chrome-relay/README-CN.md +38 -0
- package/extensions/chrome-relay/README.md +38 -0
- package/extensions/chrome-relay/background.js +1687 -0
- package/extensions/chrome-relay/manifest.json +15 -0
- package/extensions/chrome-relay/popup.html +369 -0
- package/extensions/chrome-relay/popup.js +972 -0
- package/package.json +51 -0
- package/packages/core/src/bootstrap.test.ts +10 -0
- package/packages/core/src/driver-registry.test.ts +45 -0
- package/packages/core/src/driver-registry.ts +22 -0
- package/packages/core/src/driver.ts +47 -0
- package/packages/core/src/index.ts +5 -0
- package/packages/core/src/ref-cache.test.ts +61 -0
- package/packages/core/src/ref-cache.ts +28 -0
- package/packages/core/src/session-store.test.ts +49 -0
- package/packages/core/src/session-store.ts +33 -0
- package/packages/core/src/types.ts +9 -0
- package/packages/driver-chrome-relay/src/chrome-relay-driver.test.ts +634 -0
- package/packages/driver-chrome-relay/src/chrome-relay-driver.ts +2206 -0
- package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.test.ts +264 -0
- package/packages/driver-chrome-relay/src/chrome-relay-extension-runtime.ts +521 -0
- package/packages/driver-chrome-relay/src/index.ts +26 -0
- package/packages/driver-managed/src/index.ts +22 -0
- package/packages/driver-managed/src/managed-driver.test.ts +59 -0
- package/packages/driver-managed/src/managed-driver.ts +125 -0
- package/packages/driver-managed/src/managed-local-driver.test.ts +506 -0
- package/packages/driver-managed/src/managed-local-driver.ts +2021 -0
- package/packages/driver-remote-cdp/src/index.ts +19 -0
- package/packages/driver-remote-cdp/src/remote-cdp-driver.test.ts +617 -0
- package/packages/driver-remote-cdp/src/remote-cdp-driver.ts +2042 -0
- package/packages/protocol/src/envelope.test.ts +25 -0
- package/packages/protocol/src/envelope.ts +31 -0
- package/packages/protocol/src/errors.test.ts +17 -0
- package/packages/protocol/src/errors.ts +11 -0
- package/packages/protocol/src/index.ts +3 -0
- package/packages/protocol/src/tools.ts +3 -0
- package/packages/transport-mcp-stdio/src/index.ts +3 -0
- package/packages/transport-mcp-stdio/src/sdk-server.ts +139 -0
- package/packages/transport-mcp-stdio/src/server.test.ts +281 -0
- package/packages/transport-mcp-stdio/src/server.ts +183 -0
- package/packages/transport-mcp-stdio/src/tool-map.ts +67 -0
- package/scripts/smoke.ps1 +127 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createServer, type Server as HttpServer } from "node:http";
|
|
3
|
+
|
|
4
|
+
import { WebSocket, WebSocketServer } from "ws";
|
|
5
|
+
|
|
6
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 5_000;
|
|
7
|
+
const BRIDGE_PATH = "/bridge";
|
|
8
|
+
const JSON_VERSION_PATH = "/json/version";
|
|
9
|
+
const STATUS_PATH = "/browserctl/relay/status";
|
|
10
|
+
|
|
11
|
+
type PendingRequest = {
|
|
12
|
+
resolve: (value: unknown) => void;
|
|
13
|
+
reject: (error: unknown) => void;
|
|
14
|
+
timer: NodeJS.Timeout;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export type ChromeRelayExtensionBridgeConsoleEvent = {
|
|
18
|
+
kind: "console";
|
|
19
|
+
tabId: number;
|
|
20
|
+
entry: {
|
|
21
|
+
type: string;
|
|
22
|
+
text: string;
|
|
23
|
+
location?: {
|
|
24
|
+
url?: string;
|
|
25
|
+
lineNumber?: number;
|
|
26
|
+
columnNumber?: number;
|
|
27
|
+
};
|
|
28
|
+
};
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type ChromeRelayExtensionBridgeNetworkEvent = {
|
|
32
|
+
kind: "response";
|
|
33
|
+
tabId: number;
|
|
34
|
+
response: {
|
|
35
|
+
requestId: string;
|
|
36
|
+
url: string;
|
|
37
|
+
status?: number;
|
|
38
|
+
method?: string;
|
|
39
|
+
resourceType?: string;
|
|
40
|
+
body?: string;
|
|
41
|
+
encoding?: "utf8" | "base64";
|
|
42
|
+
};
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export type ChromeRelayExtensionBridgeEvent =
|
|
46
|
+
| ChromeRelayExtensionBridgeConsoleEvent
|
|
47
|
+
| ChromeRelayExtensionBridgeNetworkEvent;
|
|
48
|
+
|
|
49
|
+
export type ChromeRelayExtensionBridgeConfig = {
|
|
50
|
+
relayUrl: string;
|
|
51
|
+
token?: string;
|
|
52
|
+
requestTimeoutMs?: number;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
export type ChromeRelayExtensionBridgeStatus = {
|
|
56
|
+
connected: boolean;
|
|
57
|
+
extensionId?: string;
|
|
58
|
+
websocketUrl: string;
|
|
59
|
+
relayUrl: string;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export type ChromeRelayExtensionBridge = {
|
|
63
|
+
relayUrl: string;
|
|
64
|
+
websocketUrl: string;
|
|
65
|
+
isConnected(): boolean;
|
|
66
|
+
getStatus(): ChromeRelayExtensionBridgeStatus;
|
|
67
|
+
invoke(method: string, params?: Record<string, unknown>): Promise<unknown>;
|
|
68
|
+
onEvent(listener: (event: ChromeRelayExtensionBridgeEvent) => void): () => void;
|
|
69
|
+
close(): Promise<void>;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function resolveNonEmptyString(value: string | undefined): string | undefined {
|
|
73
|
+
if (value === undefined) {
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const trimmedValue = value.trim();
|
|
78
|
+
return trimmedValue.length === 0 ? undefined : trimmedValue;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
82
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function readObjectStringProperty(value: unknown, key: string): string | undefined {
|
|
86
|
+
if (!isObjectRecord(value)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const propertyValue = value[key];
|
|
91
|
+
return typeof propertyValue === "string" ? propertyValue : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readObjectNumberProperty(value: unknown, key: string): number | undefined {
|
|
95
|
+
if (!isObjectRecord(value)) {
|
|
96
|
+
return undefined;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const propertyValue = value[key];
|
|
100
|
+
return typeof propertyValue === "number" ? propertyValue : undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function parseBridgeEvent(value: unknown): ChromeRelayExtensionBridgeEvent | undefined {
|
|
104
|
+
if (!isObjectRecord(value)) {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const kind = readObjectStringProperty(value, "kind");
|
|
109
|
+
const tabId = readObjectNumberProperty(value, "tabId");
|
|
110
|
+
if (tabId === undefined || !Number.isFinite(tabId)) {
|
|
111
|
+
return undefined;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (kind === "console") {
|
|
115
|
+
const rawEntry = isObjectRecord(value.entry) ? value.entry : undefined;
|
|
116
|
+
const type = readObjectStringProperty(rawEntry, "type") ?? "log";
|
|
117
|
+
const text = readObjectStringProperty(rawEntry, "text") ?? "";
|
|
118
|
+
const rawLocation = isObjectRecord(rawEntry?.location) ? rawEntry.location : undefined;
|
|
119
|
+
const url = readObjectStringProperty(rawLocation, "url");
|
|
120
|
+
const lineNumber = readObjectNumberProperty(rawLocation, "lineNumber");
|
|
121
|
+
const columnNumber = readObjectNumberProperty(rawLocation, "columnNumber");
|
|
122
|
+
|
|
123
|
+
return {
|
|
124
|
+
kind: "console",
|
|
125
|
+
tabId,
|
|
126
|
+
entry: {
|
|
127
|
+
type,
|
|
128
|
+
text,
|
|
129
|
+
...((url !== undefined || lineNumber !== undefined || columnNumber !== undefined)
|
|
130
|
+
? {
|
|
131
|
+
location: {
|
|
132
|
+
...(url !== undefined ? { url } : {}),
|
|
133
|
+
...(lineNumber !== undefined ? { lineNumber } : {}),
|
|
134
|
+
...(columnNumber !== undefined ? { columnNumber } : {})
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
: {})
|
|
138
|
+
}
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (kind === "response") {
|
|
143
|
+
const rawResponse = isObjectRecord(value.response) ? value.response : undefined;
|
|
144
|
+
const requestId = readObjectStringProperty(rawResponse, "requestId");
|
|
145
|
+
const url = readObjectStringProperty(rawResponse, "url");
|
|
146
|
+
if (requestId === undefined || url === undefined) {
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const status = readObjectNumberProperty(rawResponse, "status");
|
|
151
|
+
const method = readObjectStringProperty(rawResponse, "method");
|
|
152
|
+
const resourceType = readObjectStringProperty(rawResponse, "resourceType");
|
|
153
|
+
const body = readObjectStringProperty(rawResponse, "body");
|
|
154
|
+
const encodingRaw = readObjectStringProperty(rawResponse, "encoding");
|
|
155
|
+
const encoding = encodingRaw === "base64" ? "base64" : encodingRaw === "utf8" ? "utf8" : undefined;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
kind: "response",
|
|
159
|
+
tabId,
|
|
160
|
+
response: {
|
|
161
|
+
requestId,
|
|
162
|
+
url,
|
|
163
|
+
...(status !== undefined ? { status } : {}),
|
|
164
|
+
...(method !== undefined ? { method } : {}),
|
|
165
|
+
...(resourceType !== undefined ? { resourceType } : {}),
|
|
166
|
+
...(body !== undefined ? { body } : {}),
|
|
167
|
+
...(encoding !== undefined ? { encoding } : {})
|
|
168
|
+
}
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseRelayHttpUrl(relayUrl: string): URL {
|
|
176
|
+
const parsedRelayUrl = new URL(relayUrl);
|
|
177
|
+
if (parsedRelayUrl.protocol !== "http:") {
|
|
178
|
+
throw new Error(
|
|
179
|
+
`Chrome relay extension bridge requires http relayUrl. Received: ${relayUrl}`
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return parsedRelayUrl;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getRelayPort(parsedRelayUrl: URL): number {
|
|
187
|
+
if (parsedRelayUrl.port.length > 0) {
|
|
188
|
+
const parsedPort = Number.parseInt(parsedRelayUrl.port, 10);
|
|
189
|
+
if (Number.isFinite(parsedPort) && parsedPort > 0) {
|
|
190
|
+
return parsedPort;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return 80;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function createResponsePayload(payload: unknown): string {
|
|
198
|
+
return JSON.stringify(payload);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function createChromeRelayExtensionBridge(
|
|
202
|
+
config: ChromeRelayExtensionBridgeConfig
|
|
203
|
+
): ChromeRelayExtensionBridge {
|
|
204
|
+
const parsedRelayUrl = parseRelayHttpUrl(config.relayUrl);
|
|
205
|
+
const host = parsedRelayUrl.hostname;
|
|
206
|
+
const port = getRelayPort(parsedRelayUrl);
|
|
207
|
+
const expectedToken = resolveNonEmptyString(config.token);
|
|
208
|
+
if (expectedToken === undefined) {
|
|
209
|
+
throw new Error(
|
|
210
|
+
"BROWSERD_CHROME_RELAY_EXTENSION_TOKEN is required for chrome-relay extension bridge."
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const requestTimeoutMs = config.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
|
215
|
+
const pendingRequests = new Map<string, PendingRequest>();
|
|
216
|
+
const eventListeners = new Set<(event: ChromeRelayExtensionBridgeEvent) => void>();
|
|
217
|
+
let extensionSocket: WebSocket | undefined;
|
|
218
|
+
let extensionId: string | undefined;
|
|
219
|
+
let extensionAuthenticated = false;
|
|
220
|
+
|
|
221
|
+
const websocketUrlBase = `ws://${host}:${port}${BRIDGE_PATH}`;
|
|
222
|
+
const websocketUrl = websocketUrlBase;
|
|
223
|
+
|
|
224
|
+
const httpServer: HttpServer = createServer((request, response) => {
|
|
225
|
+
const requestUrl = request.url ?? "/";
|
|
226
|
+
const parsedPath = requestUrl.split("?")[0];
|
|
227
|
+
|
|
228
|
+
if (parsedPath === JSON_VERSION_PATH) {
|
|
229
|
+
response.statusCode = 200;
|
|
230
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
231
|
+
response.end(
|
|
232
|
+
createResponsePayload({
|
|
233
|
+
Browser: "browserctl-extension-relay",
|
|
234
|
+
webSocketDebuggerUrl: websocketUrlBase
|
|
235
|
+
})
|
|
236
|
+
);
|
|
237
|
+
return;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (parsedPath === STATUS_PATH) {
|
|
241
|
+
response.statusCode = 200;
|
|
242
|
+
response.setHeader("content-type", "application/json; charset=utf-8");
|
|
243
|
+
response.end(
|
|
244
|
+
createResponsePayload({
|
|
245
|
+
connected:
|
|
246
|
+
extensionSocket !== undefined && extensionSocket.readyState === WebSocket.OPEN,
|
|
247
|
+
extensionId,
|
|
248
|
+
relayUrl: config.relayUrl,
|
|
249
|
+
websocketUrl: websocketUrlBase
|
|
250
|
+
})
|
|
251
|
+
);
|
|
252
|
+
return;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
response.statusCode = 404;
|
|
256
|
+
response.end();
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
const wsServer = new WebSocketServer({
|
|
260
|
+
noServer: true
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
function rejectPendingRequests(reason: string): void {
|
|
264
|
+
for (const [requestId, pending] of pendingRequests.entries()) {
|
|
265
|
+
pendingRequests.delete(requestId);
|
|
266
|
+
clearTimeout(pending.timer);
|
|
267
|
+
pending.reject(new Error(reason));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
function clearExtensionConnection(reason: string): void {
|
|
272
|
+
extensionSocket = undefined;
|
|
273
|
+
extensionId = undefined;
|
|
274
|
+
extensionAuthenticated = false;
|
|
275
|
+
rejectPendingRequests(reason);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
wsServer.on("connection", (socket) => {
|
|
279
|
+
if (extensionSocket !== undefined && extensionSocket.readyState === WebSocket.OPEN) {
|
|
280
|
+
extensionSocket.close(1000, "Superseded by a newer extension connection.");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
extensionSocket = socket;
|
|
284
|
+
extensionId = undefined;
|
|
285
|
+
extensionAuthenticated = false;
|
|
286
|
+
|
|
287
|
+
const authDeadlineMs = Math.max(200, Math.min(requestTimeoutMs, 5_000));
|
|
288
|
+
const authTimer = setTimeout(() => {
|
|
289
|
+
if (socket.readyState === WebSocket.OPEN && !extensionAuthenticated) {
|
|
290
|
+
socket.close(1008, "Missing authentication token");
|
|
291
|
+
}
|
|
292
|
+
}, authDeadlineMs);
|
|
293
|
+
|
|
294
|
+
const clearAuthTimer = () => {
|
|
295
|
+
clearTimeout(authTimer);
|
|
296
|
+
};
|
|
297
|
+
|
|
298
|
+
socket.on("message", (message) => {
|
|
299
|
+
let parsedMessage: unknown;
|
|
300
|
+
try {
|
|
301
|
+
parsedMessage = JSON.parse(String(message));
|
|
302
|
+
} catch {
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (!isObjectRecord(parsedMessage)) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
if (parsedMessage.type === "hello") {
|
|
311
|
+
const providedToken = resolveNonEmptyString(parsedMessage.token as string | undefined);
|
|
312
|
+
if (providedToken !== expectedToken) {
|
|
313
|
+
socket.close(1008, "Invalid token");
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
clearAuthTimer();
|
|
318
|
+
extensionAuthenticated = true;
|
|
319
|
+
extensionId = resolveNonEmptyString(parsedMessage.extensionId as string | undefined);
|
|
320
|
+
return;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (!extensionAuthenticated) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
if (parsedMessage.type === "event") {
|
|
328
|
+
const event = parseBridgeEvent(parsedMessage.event);
|
|
329
|
+
if (event === undefined) {
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
for (const listener of eventListeners) {
|
|
334
|
+
listener(event);
|
|
335
|
+
}
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (parsedMessage.type !== "response") {
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const requestId = resolveNonEmptyString(parsedMessage.id as string | undefined);
|
|
344
|
+
if (requestId === undefined) {
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
const pending = pendingRequests.get(requestId);
|
|
349
|
+
if (pending === undefined) {
|
|
350
|
+
return;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
pendingRequests.delete(requestId);
|
|
354
|
+
clearTimeout(pending.timer);
|
|
355
|
+
if (parsedMessage.ok === true) {
|
|
356
|
+
pending.resolve(parsedMessage.result);
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const errorMessage = resolveNonEmptyString(
|
|
361
|
+
isObjectRecord(parsedMessage.error) ? (parsedMessage.error.message as string | undefined) : undefined
|
|
362
|
+
);
|
|
363
|
+
pending.reject(new Error(errorMessage ?? "Extension relay request failed."));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
socket.on("close", () => {
|
|
367
|
+
clearAuthTimer();
|
|
368
|
+
if (extensionSocket === socket) {
|
|
369
|
+
clearExtensionConnection("Extension websocket disconnected.");
|
|
370
|
+
}
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
socket.on("error", () => {
|
|
374
|
+
clearAuthTimer();
|
|
375
|
+
if (extensionSocket === socket) {
|
|
376
|
+
clearExtensionConnection("Extension websocket encountered an error.");
|
|
377
|
+
}
|
|
378
|
+
});
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
httpServer.on("upgrade", (request, socket, head) => {
|
|
382
|
+
const requestUrl = new URL(request.url ?? BRIDGE_PATH, parsedRelayUrl);
|
|
383
|
+
if (requestUrl.pathname !== BRIDGE_PATH) {
|
|
384
|
+
socket.destroy();
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
wsServer.handleUpgrade(request, socket, head, (upgradedSocket) => {
|
|
389
|
+
wsServer.emit("connection", upgradedSocket, request);
|
|
390
|
+
});
|
|
391
|
+
});
|
|
392
|
+
|
|
393
|
+
httpServer.listen(port, host);
|
|
394
|
+
|
|
395
|
+
return {
|
|
396
|
+
relayUrl: config.relayUrl,
|
|
397
|
+
websocketUrl,
|
|
398
|
+
isConnected: () =>
|
|
399
|
+
extensionSocket !== undefined &&
|
|
400
|
+
extensionSocket.readyState === WebSocket.OPEN &&
|
|
401
|
+
extensionAuthenticated,
|
|
402
|
+
getStatus: () => ({
|
|
403
|
+
connected:
|
|
404
|
+
extensionSocket !== undefined &&
|
|
405
|
+
extensionSocket.readyState === WebSocket.OPEN &&
|
|
406
|
+
extensionAuthenticated,
|
|
407
|
+
extensionId,
|
|
408
|
+
websocketUrl: websocketUrlBase,
|
|
409
|
+
relayUrl: config.relayUrl
|
|
410
|
+
}),
|
|
411
|
+
invoke: async (method, params) => {
|
|
412
|
+
if (
|
|
413
|
+
extensionSocket === undefined ||
|
|
414
|
+
extensionSocket.readyState !== WebSocket.OPEN ||
|
|
415
|
+
!extensionAuthenticated
|
|
416
|
+
) {
|
|
417
|
+
throw new Error("Chrome relay extension is not connected.");
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
const requestId = `request:extension:${randomUUID()}`;
|
|
421
|
+
const response = await new Promise<unknown>((resolvePromise, rejectPromise) => {
|
|
422
|
+
const timer = setTimeout(() => {
|
|
423
|
+
pendingRequests.delete(requestId);
|
|
424
|
+
rejectPromise(
|
|
425
|
+
new Error(
|
|
426
|
+
`Timed out waiting for extension response after ${requestTimeoutMs}ms (${method}).`
|
|
427
|
+
)
|
|
428
|
+
);
|
|
429
|
+
}, requestTimeoutMs);
|
|
430
|
+
|
|
431
|
+
pendingRequests.set(requestId, {
|
|
432
|
+
resolve: resolvePromise,
|
|
433
|
+
reject: rejectPromise,
|
|
434
|
+
timer
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
try {
|
|
438
|
+
extensionSocket.send(
|
|
439
|
+
JSON.stringify({
|
|
440
|
+
type: "request",
|
|
441
|
+
id: requestId,
|
|
442
|
+
method,
|
|
443
|
+
params
|
|
444
|
+
}),
|
|
445
|
+
(error) => {
|
|
446
|
+
if (error === undefined || error === null) {
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const pending = pendingRequests.get(requestId);
|
|
451
|
+
if (pending === undefined) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
pendingRequests.delete(requestId);
|
|
456
|
+
clearTimeout(pending.timer);
|
|
457
|
+
rejectPromise(error);
|
|
458
|
+
}
|
|
459
|
+
);
|
|
460
|
+
} catch (error) {
|
|
461
|
+
const pending = pendingRequests.get(requestId);
|
|
462
|
+
if (pending !== undefined) {
|
|
463
|
+
pendingRequests.delete(requestId);
|
|
464
|
+
clearTimeout(pending.timer);
|
|
465
|
+
}
|
|
466
|
+
rejectPromise(error);
|
|
467
|
+
}
|
|
468
|
+
});
|
|
469
|
+
|
|
470
|
+
return response;
|
|
471
|
+
},
|
|
472
|
+
onEvent: (listener) => {
|
|
473
|
+
eventListeners.add(listener);
|
|
474
|
+
return () => {
|
|
475
|
+
eventListeners.delete(listener);
|
|
476
|
+
};
|
|
477
|
+
},
|
|
478
|
+
close: async () => {
|
|
479
|
+
rejectPendingRequests("Extension bridge closed.");
|
|
480
|
+
eventListeners.clear();
|
|
481
|
+
if (extensionSocket !== undefined) {
|
|
482
|
+
try {
|
|
483
|
+
extensionSocket.close(1000, "Bridge shutdown");
|
|
484
|
+
} catch {
|
|
485
|
+
// Ignore websocket close errors during shutdown.
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
extensionSocket = undefined;
|
|
489
|
+
extensionId = undefined;
|
|
490
|
+
|
|
491
|
+
await new Promise<void>((resolve) => {
|
|
492
|
+
wsServer.close(() => {
|
|
493
|
+
resolve();
|
|
494
|
+
});
|
|
495
|
+
});
|
|
496
|
+
|
|
497
|
+
if (httpServer.listening) {
|
|
498
|
+
await new Promise<void>((resolve) => {
|
|
499
|
+
httpServer.close(() => {
|
|
500
|
+
resolve();
|
|
501
|
+
});
|
|
502
|
+
});
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
};
|
|
506
|
+
}
|