@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,432 @@
|
|
|
1
|
+
import { createContainer, loadBrowserdConfig, type BrowserdContainer } from "./container";
|
|
2
|
+
import { createMcpSdkServer } from "../../../packages/transport-mcp-stdio/src";
|
|
3
|
+
import { createServer, Socket } from "node:net";
|
|
4
|
+
import { Readable, Writable } from "node:stream";
|
|
5
|
+
|
|
6
|
+
const UNKNOWN_TRACE_ID = "trace:unknown";
|
|
7
|
+
const UNKNOWN_SESSION_ID = "session:unknown";
|
|
8
|
+
const INVALID_REQUEST_CODE = "E_INVALID_ARG";
|
|
9
|
+
const DEFAULT_TCP_HOST = "127.0.0.1";
|
|
10
|
+
const DEFAULT_TCP_PORT = 41337;
|
|
11
|
+
const DEFAULT_SERVER_VERSION = "0.1.0";
|
|
12
|
+
|
|
13
|
+
type StdioToolRequest = {
|
|
14
|
+
name: string;
|
|
15
|
+
arguments?: unknown;
|
|
16
|
+
traceId?: string;
|
|
17
|
+
id?: unknown;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
type StdioTransport = {
|
|
21
|
+
input: Readable;
|
|
22
|
+
output: Writable;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
type StdioProtocol = "mcp" | "legacy";
|
|
26
|
+
|
|
27
|
+
type TcpTransport = {
|
|
28
|
+
host: string;
|
|
29
|
+
port: number;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
type ErrorEnvelope = {
|
|
33
|
+
id?: unknown;
|
|
34
|
+
ok: false;
|
|
35
|
+
traceId: string;
|
|
36
|
+
sessionId: string;
|
|
37
|
+
error: {
|
|
38
|
+
code: string;
|
|
39
|
+
message: string;
|
|
40
|
+
};
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
type RequestMetadata = {
|
|
44
|
+
id?: unknown;
|
|
45
|
+
traceId?: string;
|
|
46
|
+
sessionId?: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
class ProcessLineError extends Error {
|
|
50
|
+
readonly metadata: RequestMetadata;
|
|
51
|
+
|
|
52
|
+
constructor(message: string, metadata: RequestMetadata) {
|
|
53
|
+
super(message);
|
|
54
|
+
this.metadata = metadata;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveNonEmptyString(value: unknown): string | undefined {
|
|
59
|
+
if (typeof value !== "string") {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const trimmedValue = value.trim();
|
|
64
|
+
return trimmedValue.length === 0 ? undefined : trimmedValue;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function extractRequestMetadata(value: unknown): RequestMetadata {
|
|
68
|
+
if (!isObjectRecord(value)) {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const metadata: RequestMetadata = {};
|
|
73
|
+
if (Object.prototype.hasOwnProperty.call(value, "id")) {
|
|
74
|
+
metadata.id = value.id;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const traceId = resolveNonEmptyString(value.traceId);
|
|
78
|
+
if (traceId !== undefined) {
|
|
79
|
+
metadata.traceId = traceId;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const args = value.arguments;
|
|
83
|
+
if (isObjectRecord(args)) {
|
|
84
|
+
const sessionId = resolveNonEmptyString(args.sessionId);
|
|
85
|
+
if (sessionId !== undefined) {
|
|
86
|
+
metadata.sessionId = sessionId;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return metadata;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function toErrorMessage(error: unknown): string {
|
|
94
|
+
return error instanceof Error ? error.message : "Unexpected request handling failure.";
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function createInvalidRequestEnvelope(
|
|
98
|
+
message: string,
|
|
99
|
+
metadata: RequestMetadata = {}
|
|
100
|
+
): ErrorEnvelope {
|
|
101
|
+
const envelope: ErrorEnvelope = {
|
|
102
|
+
ok: false,
|
|
103
|
+
traceId: metadata.traceId ?? UNKNOWN_TRACE_ID,
|
|
104
|
+
sessionId: metadata.sessionId ?? UNKNOWN_SESSION_ID,
|
|
105
|
+
error: {
|
|
106
|
+
code: INVALID_REQUEST_CODE,
|
|
107
|
+
message
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
if (metadata.id !== undefined) {
|
|
112
|
+
envelope.id = metadata.id;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return envelope;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
119
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function normalizeToolRequest(value: unknown): ToolRequestParseResult {
|
|
123
|
+
if (!isObjectRecord(value)) {
|
|
124
|
+
return {
|
|
125
|
+
ok: false,
|
|
126
|
+
error: createInvalidRequestEnvelope("Request must be a JSON object.")
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const metadata = extractRequestMetadata(value);
|
|
131
|
+
const { id, name, arguments: args, traceId } = value;
|
|
132
|
+
if (typeof name !== "string" || name.trim().length === 0) {
|
|
133
|
+
return {
|
|
134
|
+
ok: false,
|
|
135
|
+
error: createInvalidRequestEnvelope(
|
|
136
|
+
'Request field "name" must be a non-empty string.',
|
|
137
|
+
metadata
|
|
138
|
+
)
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const request: StdioToolRequest = {
|
|
143
|
+
name: name.trim(),
|
|
144
|
+
arguments: args
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
if (typeof traceId === "string") {
|
|
148
|
+
request.traceId = traceId;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (Object.prototype.hasOwnProperty.call(value, "id")) {
|
|
152
|
+
request.id = id;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
return {
|
|
156
|
+
ok: true,
|
|
157
|
+
request
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
type ToolRequestParseResult =
|
|
162
|
+
| {
|
|
163
|
+
ok: true;
|
|
164
|
+
request: StdioToolRequest;
|
|
165
|
+
}
|
|
166
|
+
| {
|
|
167
|
+
ok: false;
|
|
168
|
+
error: ErrorEnvelope;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
function writeJsonLine(output: Writable, payload: unknown): void {
|
|
172
|
+
output.write(`${JSON.stringify(payload)}\n`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export type BrowserdRuntime = {
|
|
176
|
+
container: BrowserdContainer;
|
|
177
|
+
transport: "stdio" | "tcp";
|
|
178
|
+
listening?: TcpTransport;
|
|
179
|
+
mcpStdioStarted: true;
|
|
180
|
+
close(): void;
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
type LineWriter = (payload: unknown) => void;
|
|
184
|
+
|
|
185
|
+
function createLineProcessor(container: BrowserdContainer, writer: LineWriter) {
|
|
186
|
+
return async (line: string): Promise<void> => {
|
|
187
|
+
const trimmedLine = line.trim();
|
|
188
|
+
if (trimmedLine.length === 0) {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
let parsedRequest: unknown;
|
|
193
|
+
try {
|
|
194
|
+
parsedRequest = JSON.parse(trimmedLine);
|
|
195
|
+
} catch {
|
|
196
|
+
writer(createInvalidRequestEnvelope("Invalid JSON request."));
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const requestMetadata = extractRequestMetadata(parsedRequest);
|
|
201
|
+
try {
|
|
202
|
+
const normalizedRequest = normalizeToolRequest(parsedRequest);
|
|
203
|
+
if (!normalizedRequest.ok) {
|
|
204
|
+
writer(normalizedRequest.error);
|
|
205
|
+
return;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const request = normalizedRequest.request;
|
|
209
|
+
const response = await container.mcpServer.callTool({
|
|
210
|
+
name: request.name,
|
|
211
|
+
arguments: request.arguments,
|
|
212
|
+
traceId: request.traceId
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
const payload = request.id === undefined ? response : { id: request.id, ...response };
|
|
216
|
+
writer(payload);
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw new ProcessLineError(toErrorMessage(error), requestMetadata);
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function createLineQueue(
|
|
224
|
+
processLine: (line: string) => Promise<void>,
|
|
225
|
+
writer: LineWriter
|
|
226
|
+
): (line: string) => void {
|
|
227
|
+
let processing = Promise.resolve();
|
|
228
|
+
|
|
229
|
+
return (line: string) => {
|
|
230
|
+
processing = processing
|
|
231
|
+
.then(() => processLine(line))
|
|
232
|
+
.catch((error: unknown) => {
|
|
233
|
+
if (error instanceof ProcessLineError) {
|
|
234
|
+
writer(createInvalidRequestEnvelope(error.message, error.metadata));
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
writer(createInvalidRequestEnvelope(toErrorMessage(error)));
|
|
239
|
+
});
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function attachStreamReader(
|
|
244
|
+
input: Readable,
|
|
245
|
+
onLine: (line: string) => void
|
|
246
|
+
): () => void {
|
|
247
|
+
let buffer = "";
|
|
248
|
+
const onData = (chunk: string | Buffer) => {
|
|
249
|
+
buffer += typeof chunk === "string" ? chunk : chunk.toString("utf8");
|
|
250
|
+
|
|
251
|
+
let lineBreakIndex = buffer.indexOf("\n");
|
|
252
|
+
while (lineBreakIndex >= 0) {
|
|
253
|
+
const line = buffer.slice(0, lineBreakIndex);
|
|
254
|
+
buffer = buffer.slice(lineBreakIndex + 1);
|
|
255
|
+
onLine(line);
|
|
256
|
+
lineBreakIndex = buffer.indexOf("\n");
|
|
257
|
+
}
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
input.setEncoding("utf8");
|
|
261
|
+
input.on("data", onData);
|
|
262
|
+
input.resume();
|
|
263
|
+
return () => {
|
|
264
|
+
input.off("data", onData);
|
|
265
|
+
input.pause();
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function toTcpTransport(value: BootstrapBrowserdOptions): TcpTransport {
|
|
270
|
+
const env = value.env ?? process.env;
|
|
271
|
+
const envPort = toPositiveNumber(env.BROWSERD_PORT);
|
|
272
|
+
const optionPort = value.port;
|
|
273
|
+
return {
|
|
274
|
+
host: value.host ?? env.BROWSERD_HOST ?? DEFAULT_TCP_HOST,
|
|
275
|
+
port: optionPort ?? envPort ?? DEFAULT_TCP_PORT
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
function toPositiveNumber(value: string | undefined): number | undefined {
|
|
280
|
+
if (value === undefined) {
|
|
281
|
+
return undefined;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const parsed = Number.parseInt(value, 10);
|
|
285
|
+
return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function resolveTransportMode(options: BootstrapBrowserdOptions): "stdio" | "tcp" {
|
|
289
|
+
if (options.transport !== undefined) {
|
|
290
|
+
return options.transport;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const env = options.env ?? process.env;
|
|
294
|
+
return env.BROWSERD_TRANSPORT === "tcp" ? "tcp" : "stdio";
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
export function startMcpStdioServer(
|
|
298
|
+
container: BrowserdContainer,
|
|
299
|
+
transport: StdioTransport = { input: process.stdin, output: process.stdout }
|
|
300
|
+
): BrowserdRuntime {
|
|
301
|
+
const mcpSdkServer = createMcpSdkServer({
|
|
302
|
+
toolMap: container.mcpServer.toolMap,
|
|
303
|
+
serverInfo: {
|
|
304
|
+
name: "browserd",
|
|
305
|
+
version: DEFAULT_SERVER_VERSION
|
|
306
|
+
}
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// MCP server startup is async but starts listening synchronously before this Promise resolves.
|
|
310
|
+
void mcpSdkServer.connectStdio(transport).catch(() => undefined);
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
container,
|
|
314
|
+
transport: "stdio",
|
|
315
|
+
mcpStdioStarted: true,
|
|
316
|
+
close() {
|
|
317
|
+
void mcpSdkServer.close();
|
|
318
|
+
container.close();
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function startLegacyStdioServer(
|
|
324
|
+
container: BrowserdContainer,
|
|
325
|
+
transport: StdioTransport = { input: process.stdin, output: process.stdout }
|
|
326
|
+
): BrowserdRuntime {
|
|
327
|
+
const writer: LineWriter = (payload) => {
|
|
328
|
+
writeJsonLine(transport.output, payload);
|
|
329
|
+
};
|
|
330
|
+
const processLine = createLineProcessor(container, writer);
|
|
331
|
+
const queueLine = createLineQueue(processLine, writer);
|
|
332
|
+
const detach = attachStreamReader(transport.input, queueLine);
|
|
333
|
+
|
|
334
|
+
return {
|
|
335
|
+
container,
|
|
336
|
+
transport: "stdio",
|
|
337
|
+
mcpStdioStarted: true,
|
|
338
|
+
close() {
|
|
339
|
+
detach();
|
|
340
|
+
container.close();
|
|
341
|
+
}
|
|
342
|
+
};
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
export function startMcpTcpServer(container: BrowserdContainer, transport: TcpTransport): BrowserdRuntime {
|
|
346
|
+
const server = createServer();
|
|
347
|
+
const sockets = new Set<Socket>();
|
|
348
|
+
|
|
349
|
+
server.on("connection", (socket) => {
|
|
350
|
+
sockets.add(socket);
|
|
351
|
+
socket.setEncoding("utf8");
|
|
352
|
+
|
|
353
|
+
const writer: LineWriter = (payload) => {
|
|
354
|
+
socket.write(`${JSON.stringify(payload)}\n`);
|
|
355
|
+
};
|
|
356
|
+
|
|
357
|
+
const processLine = createLineProcessor(container, writer);
|
|
358
|
+
const queueLine = createLineQueue(processLine, writer);
|
|
359
|
+
const detachReader = attachStreamReader(socket, queueLine);
|
|
360
|
+
|
|
361
|
+
socket.on("close", () => {
|
|
362
|
+
detachReader();
|
|
363
|
+
sockets.delete(socket);
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
socket.on("error", () => {
|
|
367
|
+
detachReader();
|
|
368
|
+
sockets.delete(socket);
|
|
369
|
+
});
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
server.listen(transport.port, transport.host);
|
|
373
|
+
|
|
374
|
+
return {
|
|
375
|
+
container,
|
|
376
|
+
transport: "tcp",
|
|
377
|
+
listening: transport,
|
|
378
|
+
mcpStdioStarted: true,
|
|
379
|
+
close() {
|
|
380
|
+
for (const socket of sockets) {
|
|
381
|
+
socket.destroy();
|
|
382
|
+
}
|
|
383
|
+
sockets.clear();
|
|
384
|
+
server.close();
|
|
385
|
+
container.close();
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
type BootstrapBrowserdOptions = {
|
|
391
|
+
env?: Record<string, string | undefined>;
|
|
392
|
+
transport?: "stdio" | "tcp";
|
|
393
|
+
stdioProtocol?: StdioProtocol;
|
|
394
|
+
host?: string;
|
|
395
|
+
port?: number;
|
|
396
|
+
input?: Readable;
|
|
397
|
+
output?: Writable;
|
|
398
|
+
};
|
|
399
|
+
|
|
400
|
+
function resolveStdioProtocol(options: BootstrapBrowserdOptions): StdioProtocol {
|
|
401
|
+
if (options.stdioProtocol !== undefined) {
|
|
402
|
+
return options.stdioProtocol;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
const env = options.env ?? process.env;
|
|
406
|
+
return env.BROWSERD_STDIO_PROTOCOL === "legacy" ? "legacy" : "mcp";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
export function bootstrapBrowserd(options: BootstrapBrowserdOptions = {}): BrowserdRuntime {
|
|
410
|
+
const config = loadBrowserdConfig(options.env);
|
|
411
|
+
const container = createContainer(config);
|
|
412
|
+
const mode = resolveTransportMode(options);
|
|
413
|
+
if (mode === "tcp") {
|
|
414
|
+
if (config.authToken === undefined) {
|
|
415
|
+
container.close();
|
|
416
|
+
throw new Error(
|
|
417
|
+
"BROWSERD_AUTH_TOKEN is required when BROWSERD_TRANSPORT=tcp to prevent unauthenticated network access."
|
|
418
|
+
);
|
|
419
|
+
}
|
|
420
|
+
return startMcpTcpServer(container, toTcpTransport(options));
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
const stdioTransport: StdioTransport = {
|
|
424
|
+
input: options.input ?? process.stdin,
|
|
425
|
+
output: options.output ?? process.stdout
|
|
426
|
+
};
|
|
427
|
+
if (resolveStdioProtocol(options) === "legacy") {
|
|
428
|
+
return startLegacyStdioServer(container, stdioTransport);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
return startMcpStdioServer(container, stdioTransport);
|
|
432
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
import { createServer } from "node:net";
|
|
2
|
+
|
|
3
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
4
|
+
import { WebSocket } from "ws";
|
|
5
|
+
|
|
6
|
+
import { createChromeRelayExtensionBridge } from "./chrome-relay-extension-bridge";
|
|
7
|
+
|
|
8
|
+
async function reservePort(): Promise<number> {
|
|
9
|
+
return await new Promise<number>((resolve, reject) => {
|
|
10
|
+
const server = createServer();
|
|
11
|
+
server.once("error", reject);
|
|
12
|
+
server.listen(0, "127.0.0.1", () => {
|
|
13
|
+
const address = server.address();
|
|
14
|
+
if (typeof address !== "object" || address === null) {
|
|
15
|
+
server.close(() => resolve(0));
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const port = address.port;
|
|
20
|
+
server.close((closeError) => {
|
|
21
|
+
if (closeError !== undefined) {
|
|
22
|
+
reject(closeError);
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
resolve(port);
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function waitForCondition(predicate: () => boolean, timeoutMs = 1000): Promise<void> {
|
|
33
|
+
const start = Date.now();
|
|
34
|
+
while (!predicate()) {
|
|
35
|
+
if (Date.now() - start > timeoutMs) {
|
|
36
|
+
throw new Error("Timed out waiting for condition.");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
await new Promise<void>((resolve) => {
|
|
40
|
+
setTimeout(resolve, 10);
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type Closable = {
|
|
46
|
+
close(): Promise<void> | void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const closables: Closable[] = [];
|
|
50
|
+
|
|
51
|
+
afterEach(async () => {
|
|
52
|
+
while (closables.length > 0) {
|
|
53
|
+
const item = closables.pop();
|
|
54
|
+
if (item === undefined) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
await item.close();
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe("createChromeRelayExtensionBridge", () => {
|
|
63
|
+
it("requires token configuration for extension bridge startup", async () => {
|
|
64
|
+
const port = await reservePort();
|
|
65
|
+
|
|
66
|
+
expect(() =>
|
|
67
|
+
createChromeRelayExtensionBridge({
|
|
68
|
+
relayUrl: `http://127.0.0.1:${port}`
|
|
69
|
+
})
|
|
70
|
+
).toThrow("BROWSERD_CHROME_RELAY_EXTENSION_TOKEN");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("sends requests to extension client and receives responses", async () => {
|
|
74
|
+
const port = await reservePort();
|
|
75
|
+
const bridge = createChromeRelayExtensionBridge({
|
|
76
|
+
relayUrl: `http://127.0.0.1:${port}`,
|
|
77
|
+
token: "secret-token",
|
|
78
|
+
requestTimeoutMs: 500
|
|
79
|
+
});
|
|
80
|
+
closables.push(bridge);
|
|
81
|
+
|
|
82
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/bridge`);
|
|
83
|
+
closables.push({
|
|
84
|
+
async close() {
|
|
85
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
86
|
+
await new Promise<void>((resolve) => {
|
|
87
|
+
socket.once("close", () => resolve());
|
|
88
|
+
socket.close();
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
await new Promise<void>((resolve, reject) => {
|
|
95
|
+
socket.once("open", () => {
|
|
96
|
+
socket.send(
|
|
97
|
+
JSON.stringify({
|
|
98
|
+
type: "hello",
|
|
99
|
+
role: "extension",
|
|
100
|
+
extensionId: "ext:test",
|
|
101
|
+
token: "secret-token"
|
|
102
|
+
})
|
|
103
|
+
);
|
|
104
|
+
resolve();
|
|
105
|
+
});
|
|
106
|
+
socket.once("error", reject);
|
|
107
|
+
});
|
|
108
|
+
await waitForCondition(() => bridge.isConnected());
|
|
109
|
+
|
|
110
|
+
socket.on("message", (message) => {
|
|
111
|
+
const parsed = JSON.parse(String(message)) as Record<string, unknown>;
|
|
112
|
+
if (parsed.type !== "request") {
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
socket.send(
|
|
117
|
+
JSON.stringify({
|
|
118
|
+
type: "response",
|
|
119
|
+
id: parsed.id,
|
|
120
|
+
ok: true,
|
|
121
|
+
result: {
|
|
122
|
+
accepted: true,
|
|
123
|
+
method: parsed.method
|
|
124
|
+
}
|
|
125
|
+
})
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
await expect(bridge.invoke("tab.open", { url: "https://example.com" })).resolves.toEqual({
|
|
130
|
+
accepted: true,
|
|
131
|
+
method: "tab.open"
|
|
132
|
+
});
|
|
133
|
+
expect(bridge.isConnected()).toBe(true);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("rejects invoke when extension is not connected", async () => {
|
|
137
|
+
const port = await reservePort();
|
|
138
|
+
const bridge = createChromeRelayExtensionBridge({
|
|
139
|
+
relayUrl: `http://127.0.0.1:${port}`,
|
|
140
|
+
token: "secret-token",
|
|
141
|
+
requestTimeoutMs: 200
|
|
142
|
+
});
|
|
143
|
+
closables.push(bridge);
|
|
144
|
+
|
|
145
|
+
await expect(bridge.invoke("tab.info", { tabId: 1 })).rejects.toThrowError(
|
|
146
|
+
"Chrome relay extension is not connected."
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it("enforces token validation for extension websocket connection", async () => {
|
|
151
|
+
const port = await reservePort();
|
|
152
|
+
const bridge = createChromeRelayExtensionBridge({
|
|
153
|
+
relayUrl: `http://127.0.0.1:${port}`,
|
|
154
|
+
token: "secret-token",
|
|
155
|
+
requestTimeoutMs: 500
|
|
156
|
+
});
|
|
157
|
+
closables.push(bridge);
|
|
158
|
+
|
|
159
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/bridge`);
|
|
160
|
+
closables.push({
|
|
161
|
+
async close() {
|
|
162
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
163
|
+
await new Promise<void>((resolve) => {
|
|
164
|
+
socket.once("close", () => resolve());
|
|
165
|
+
socket.close();
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
await new Promise<void>((resolve, reject) => {
|
|
172
|
+
socket.once("open", () => {
|
|
173
|
+
socket.send(
|
|
174
|
+
JSON.stringify({
|
|
175
|
+
type: "hello",
|
|
176
|
+
role: "extension",
|
|
177
|
+
extensionId: "ext:test",
|
|
178
|
+
token: "wrong-token"
|
|
179
|
+
})
|
|
180
|
+
);
|
|
181
|
+
});
|
|
182
|
+
socket.once("close", () => resolve());
|
|
183
|
+
socket.once("error", reject);
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
expect(socket.readyState).not.toBe(WebSocket.OPEN);
|
|
187
|
+
expect(bridge.isConnected()).toBe(false);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("forwards extension event envelopes to bridge listeners", async () => {
|
|
191
|
+
const port = await reservePort();
|
|
192
|
+
const bridge = createChromeRelayExtensionBridge({
|
|
193
|
+
relayUrl: `http://127.0.0.1:${port}`,
|
|
194
|
+
token: "secret-token",
|
|
195
|
+
requestTimeoutMs: 500
|
|
196
|
+
});
|
|
197
|
+
closables.push(bridge);
|
|
198
|
+
|
|
199
|
+
const socket = new WebSocket(`ws://127.0.0.1:${port}/bridge`);
|
|
200
|
+
closables.push({
|
|
201
|
+
async close() {
|
|
202
|
+
if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING) {
|
|
203
|
+
await new Promise<void>((resolve) => {
|
|
204
|
+
socket.once("close", () => resolve());
|
|
205
|
+
socket.close();
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
await new Promise<void>((resolve, reject) => {
|
|
212
|
+
socket.once("open", () => {
|
|
213
|
+
socket.send(
|
|
214
|
+
JSON.stringify({
|
|
215
|
+
type: "hello",
|
|
216
|
+
role: "extension",
|
|
217
|
+
extensionId: "ext:test",
|
|
218
|
+
token: "secret-token"
|
|
219
|
+
})
|
|
220
|
+
);
|
|
221
|
+
resolve();
|
|
222
|
+
});
|
|
223
|
+
socket.once("error", reject);
|
|
224
|
+
});
|
|
225
|
+
await waitForCondition(() => bridge.isConnected());
|
|
226
|
+
|
|
227
|
+
const receivedEvents: Array<Record<string, unknown>> = [];
|
|
228
|
+
const detach = bridge.onEvent((event) => {
|
|
229
|
+
receivedEvents.push(event as unknown as Record<string, unknown>);
|
|
230
|
+
});
|
|
231
|
+
closables.push({
|
|
232
|
+
close() {
|
|
233
|
+
detach();
|
|
234
|
+
}
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
socket.send(
|
|
238
|
+
JSON.stringify({
|
|
239
|
+
type: "event",
|
|
240
|
+
event: {
|
|
241
|
+
kind: "console",
|
|
242
|
+
tabId: 7,
|
|
243
|
+
entry: {
|
|
244
|
+
type: "log",
|
|
245
|
+
text: "hello-event"
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
})
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
await new Promise<void>((resolve) => {
|
|
252
|
+
const start = Date.now();
|
|
253
|
+
const poll = () => {
|
|
254
|
+
if (receivedEvents.length >= 1 || Date.now() - start > 1000) {
|
|
255
|
+
resolve();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
setTimeout(poll, 10);
|
|
260
|
+
};
|
|
261
|
+
|
|
262
|
+
poll();
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
expect(receivedEvents).toHaveLength(1);
|
|
266
|
+
expect(receivedEvents[0]).toMatchObject({
|
|
267
|
+
kind: "console",
|
|
268
|
+
tabId: 7,
|
|
269
|
+
entry: {
|
|
270
|
+
type: "log",
|
|
271
|
+
text: "hello-event"
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
});
|