@anvia/browser 1.0.0-rc.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/LICENSE +21 -0
- package/README.md +106 -0
- package/dist/index.d.ts +131 -0
- package/dist/index.js +1026 -0
- package/dist/index.js.map +1 -0
- package/image/Dockerfile +35 -0
- package/image/anvia-browser-configure +3 -0
- package/image/anvia-browser-start +3 -0
- package/image/anvia-browser-version +3 -0
- package/image/configure.mjs +64 -0
- package/image/start.mjs +147 -0
- package/package.json +54 -0
- package/security/seccomp_profile.json +701 -0
package/dist/index.js
ADDED
|
@@ -0,0 +1,1026 @@
|
|
|
1
|
+
// src/docker-browser.ts
|
|
2
|
+
import { fileURLToPath } from "url";
|
|
3
|
+
|
|
4
|
+
// src/connection.ts
|
|
5
|
+
import { randomUUID } from "crypto";
|
|
6
|
+
import { chromium } from "playwright-core";
|
|
7
|
+
|
|
8
|
+
// src/errors.ts
|
|
9
|
+
var BrowserError = class extends Error {
|
|
10
|
+
constructor(message, code, options) {
|
|
11
|
+
super(message, options);
|
|
12
|
+
this.code = code;
|
|
13
|
+
this.name = "BrowserError";
|
|
14
|
+
}
|
|
15
|
+
code;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// src/connection.ts
|
|
19
|
+
async function connectPlaywrightBrowser(options) {
|
|
20
|
+
options.abortSignal?.throwIfAborted();
|
|
21
|
+
const browser = await chromium.connectOverCDP(options.endpointUrl);
|
|
22
|
+
if (options.abortSignal?.aborted) {
|
|
23
|
+
await browser.close().catch(() => void 0);
|
|
24
|
+
options.abortSignal.throwIfAborted();
|
|
25
|
+
}
|
|
26
|
+
return new PlaywrightBrowserConnectionImpl(browser, options.control);
|
|
27
|
+
}
|
|
28
|
+
var PlaywrightBrowserConnectionImpl = class {
|
|
29
|
+
browser;
|
|
30
|
+
control;
|
|
31
|
+
tabIds = /* @__PURE__ */ new WeakMap();
|
|
32
|
+
selected;
|
|
33
|
+
isClosed = false;
|
|
34
|
+
actionTail = Promise.resolve();
|
|
35
|
+
navigationPolicyKey;
|
|
36
|
+
navigationGuard = Promise.resolve();
|
|
37
|
+
constructor(browser, control) {
|
|
38
|
+
this.browser = browser;
|
|
39
|
+
this.control = control;
|
|
40
|
+
this.selected = this.pages()[0];
|
|
41
|
+
this.browser.once("disconnected", () => {
|
|
42
|
+
this.isClosed = true;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
get closed() {
|
|
46
|
+
return this.isClosed || !this.browser.isConnected();
|
|
47
|
+
}
|
|
48
|
+
async listTabs() {
|
|
49
|
+
return this.runAction(void 0, () => this.tabSummaries());
|
|
50
|
+
}
|
|
51
|
+
async tabSummaries() {
|
|
52
|
+
return Object.freeze(
|
|
53
|
+
await Promise.all(
|
|
54
|
+
this.pages().map(
|
|
55
|
+
async (page) => Object.freeze({
|
|
56
|
+
id: this.idFor(page),
|
|
57
|
+
title: await page.title(),
|
|
58
|
+
url: page.url(),
|
|
59
|
+
selected: page === this.selected
|
|
60
|
+
})
|
|
61
|
+
)
|
|
62
|
+
)
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
async runAction(abortSignal, operation) {
|
|
66
|
+
const previous = this.actionTail;
|
|
67
|
+
let release;
|
|
68
|
+
this.actionTail = new Promise((resolve) => {
|
|
69
|
+
release = resolve;
|
|
70
|
+
});
|
|
71
|
+
await previous;
|
|
72
|
+
try {
|
|
73
|
+
this.assertOpen();
|
|
74
|
+
await this.navigationGuard;
|
|
75
|
+
return await this.control.runAgentAction(() => this.withAbort(abortSignal, operation));
|
|
76
|
+
} finally {
|
|
77
|
+
release?.();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
setNavigationPolicy(policy) {
|
|
81
|
+
this.assertOpen();
|
|
82
|
+
const key = JSON.stringify(policy);
|
|
83
|
+
if (this.navigationPolicyKey !== void 0) {
|
|
84
|
+
if (this.navigationPolicyKey !== key) {
|
|
85
|
+
throw new TypeError("Browser connection already has a different navigation policy.");
|
|
86
|
+
}
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
this.navigationPolicyKey = key;
|
|
90
|
+
const routeHandler = (route) => enforceNavigationPolicy(route, policy);
|
|
91
|
+
this.navigationGuard = Promise.all(
|
|
92
|
+
this.browser.contexts().map((context) => context.route("**/*", routeHandler))
|
|
93
|
+
).then(() => void 0);
|
|
94
|
+
void this.navigationGuard.catch(() => void 0);
|
|
95
|
+
}
|
|
96
|
+
selectedPage() {
|
|
97
|
+
this.assertOpen();
|
|
98
|
+
if (this.selected !== void 0 && !this.selected.isClosed()) return this.selected;
|
|
99
|
+
const page = this.pages()[0];
|
|
100
|
+
if (page === void 0) {
|
|
101
|
+
throw new BrowserError("Browser has no open tabs.", "invalid_state");
|
|
102
|
+
}
|
|
103
|
+
this.selected = page;
|
|
104
|
+
return page;
|
|
105
|
+
}
|
|
106
|
+
async openTab() {
|
|
107
|
+
const context = this.browser.contexts()[0];
|
|
108
|
+
if (context === void 0) {
|
|
109
|
+
throw new BrowserError("Browser has no CDP context.", "invalid_state");
|
|
110
|
+
}
|
|
111
|
+
const page = await context.newPage();
|
|
112
|
+
this.selected = page;
|
|
113
|
+
this.idFor(page);
|
|
114
|
+
return page;
|
|
115
|
+
}
|
|
116
|
+
selectTab(id) {
|
|
117
|
+
const page = this.pageFor(id);
|
|
118
|
+
this.selected = page;
|
|
119
|
+
return page;
|
|
120
|
+
}
|
|
121
|
+
async closeTab(id) {
|
|
122
|
+
const page = this.pageFor(id);
|
|
123
|
+
await page.close();
|
|
124
|
+
if (page === this.selected) this.selected = this.pages()[0];
|
|
125
|
+
}
|
|
126
|
+
idFor(page) {
|
|
127
|
+
const existing = this.tabIds.get(page);
|
|
128
|
+
if (existing !== void 0) return existing;
|
|
129
|
+
const id = randomUUID();
|
|
130
|
+
this.tabIds.set(page, id);
|
|
131
|
+
return id;
|
|
132
|
+
}
|
|
133
|
+
async disconnect() {
|
|
134
|
+
if (this.isClosed) return;
|
|
135
|
+
this.isClosed = true;
|
|
136
|
+
await this.browser.close().catch(() => void 0);
|
|
137
|
+
}
|
|
138
|
+
async [Symbol.asyncDispose]() {
|
|
139
|
+
await this.disconnect();
|
|
140
|
+
}
|
|
141
|
+
pages() {
|
|
142
|
+
return this.browser.contexts().flatMap((context) => context.pages());
|
|
143
|
+
}
|
|
144
|
+
pageFor(id) {
|
|
145
|
+
const page = this.pages().find((candidate) => this.idFor(candidate) === id);
|
|
146
|
+
if (page === void 0) {
|
|
147
|
+
throw new BrowserError(`Browser tab does not exist: ${id}`, "invalid_state");
|
|
148
|
+
}
|
|
149
|
+
return page;
|
|
150
|
+
}
|
|
151
|
+
assertOpen() {
|
|
152
|
+
if (this.closed) throw new BrowserError("Browser connection is closed.", "connection_closed");
|
|
153
|
+
}
|
|
154
|
+
async withAbort(abortSignal, operation) {
|
|
155
|
+
abortSignal?.throwIfAborted();
|
|
156
|
+
if (abortSignal === void 0) return operation();
|
|
157
|
+
return new Promise((resolve, reject) => {
|
|
158
|
+
let settled = false;
|
|
159
|
+
const finish = (callback) => {
|
|
160
|
+
if (settled) return;
|
|
161
|
+
settled = true;
|
|
162
|
+
abortSignal.removeEventListener("abort", onAbort);
|
|
163
|
+
callback();
|
|
164
|
+
};
|
|
165
|
+
const onAbort = () => {
|
|
166
|
+
void this.disconnect().finally(() => finish(() => reject(abortSignal.reason)));
|
|
167
|
+
};
|
|
168
|
+
abortSignal.addEventListener("abort", onAbort, { once: true });
|
|
169
|
+
void operation().then(
|
|
170
|
+
(value) => finish(() => resolve(value)),
|
|
171
|
+
(error) => finish(() => reject(error))
|
|
172
|
+
);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
};
|
|
176
|
+
async function enforceNavigationPolicy(route, policy) {
|
|
177
|
+
const request = route.request();
|
|
178
|
+
const frame = request.frame();
|
|
179
|
+
if (request.isNavigationRequest() && frame === frame.page().mainFrame() && !isNavigationAllowed(request.url(), policy)) {
|
|
180
|
+
await route.abort("blockedbyclient");
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
await route.continue();
|
|
184
|
+
}
|
|
185
|
+
function isNavigationAllowed(value, policy) {
|
|
186
|
+
let url;
|
|
187
|
+
try {
|
|
188
|
+
url = new URL(value);
|
|
189
|
+
} catch {
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") return false;
|
|
193
|
+
if (url.username.length > 0 || url.password.length > 0) return false;
|
|
194
|
+
return policy.mode === "allow-all-http" || policy.origins.includes(url.origin);
|
|
195
|
+
}
|
|
196
|
+
function asConnection(connection) {
|
|
197
|
+
if (!(connection instanceof PlaywrightBrowserConnectionImpl)) {
|
|
198
|
+
throw new TypeError("connection must be created by DockerBrowser.connect().");
|
|
199
|
+
}
|
|
200
|
+
return connection;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// src/control.ts
|
|
204
|
+
import { randomUUID as randomUUID2 } from "crypto";
|
|
205
|
+
var BrowserControlState = class {
|
|
206
|
+
activeAgentActions = 0;
|
|
207
|
+
pendingAcquire;
|
|
208
|
+
activeLease;
|
|
209
|
+
humanPending = false;
|
|
210
|
+
destroyed = false;
|
|
211
|
+
snapshot() {
|
|
212
|
+
const lease = this.activeLease;
|
|
213
|
+
return lease === void 0 ? Object.freeze({ mode: "agent" }) : Object.freeze({
|
|
214
|
+
mode: "human",
|
|
215
|
+
ownerId: lease.ownerId,
|
|
216
|
+
expiresAt: lease.expiresAt
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
async acquireHumanControl(options) {
|
|
220
|
+
validateAcquireOptions(options);
|
|
221
|
+
this.assertActive();
|
|
222
|
+
options.abortSignal?.throwIfAborted();
|
|
223
|
+
if (this.activeLease !== void 0 || this.humanPending) {
|
|
224
|
+
throw new BrowserError("Browser human control is already acquired.", "human_controlled");
|
|
225
|
+
}
|
|
226
|
+
this.humanPending = true;
|
|
227
|
+
try {
|
|
228
|
+
if (this.activeAgentActions > 0) {
|
|
229
|
+
await new Promise((resolve, reject) => {
|
|
230
|
+
const pending = { resolve, reject };
|
|
231
|
+
if (options.abortSignal !== void 0) {
|
|
232
|
+
pending.abortSignal = options.abortSignal;
|
|
233
|
+
pending.abort = () => {
|
|
234
|
+
if (this.pendingAcquire !== pending) return;
|
|
235
|
+
this.pendingAcquire = void 0;
|
|
236
|
+
reject(options.abortSignal?.reason);
|
|
237
|
+
};
|
|
238
|
+
options.abortSignal.addEventListener("abort", pending.abort, { once: true });
|
|
239
|
+
}
|
|
240
|
+
this.pendingAcquire = pending;
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
this.assertActive();
|
|
244
|
+
options.abortSignal?.throwIfAborted();
|
|
245
|
+
const lease = new BrowserHumanControlLeaseImpl({
|
|
246
|
+
owner: this,
|
|
247
|
+
ownerId: options.ownerId,
|
|
248
|
+
leaseTimeoutMs: options.leaseTimeoutMs
|
|
249
|
+
});
|
|
250
|
+
this.activeLease = lease;
|
|
251
|
+
return lease;
|
|
252
|
+
} finally {
|
|
253
|
+
this.humanPending = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
async runAgentAction(operation) {
|
|
257
|
+
this.assertActive();
|
|
258
|
+
if (this.activeLease !== void 0 || this.humanPending) {
|
|
259
|
+
throw new BrowserError("Browser is controlled by a human viewer.", "human_controlled");
|
|
260
|
+
}
|
|
261
|
+
this.activeAgentActions += 1;
|
|
262
|
+
try {
|
|
263
|
+
return await operation();
|
|
264
|
+
} finally {
|
|
265
|
+
this.activeAgentActions -= 1;
|
|
266
|
+
if (this.activeAgentActions === 0) this.resolvePendingAcquire();
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
renewLease(lease, options) {
|
|
270
|
+
if (this.activeLease !== lease) {
|
|
271
|
+
throw new BrowserError("Browser human control lease is no longer active.", "invalid_state");
|
|
272
|
+
}
|
|
273
|
+
assertPositiveSafeInteger(options.leaseTimeoutMs, "leaseTimeoutMs");
|
|
274
|
+
lease.resetExpiration(options.leaseTimeoutMs);
|
|
275
|
+
return this.snapshot();
|
|
276
|
+
}
|
|
277
|
+
releaseLease(lease) {
|
|
278
|
+
if (this.activeLease !== lease) return;
|
|
279
|
+
this.activeLease = void 0;
|
|
280
|
+
lease.markReleased();
|
|
281
|
+
}
|
|
282
|
+
destroy() {
|
|
283
|
+
if (this.destroyed) return;
|
|
284
|
+
this.destroyed = true;
|
|
285
|
+
this.activeLease?.release();
|
|
286
|
+
if (this.pendingAcquire !== void 0) {
|
|
287
|
+
const pending = this.pendingAcquire;
|
|
288
|
+
this.pendingAcquire = void 0;
|
|
289
|
+
if (pending.abort !== void 0) {
|
|
290
|
+
pending.abortSignal?.removeEventListener("abort", pending.abort);
|
|
291
|
+
}
|
|
292
|
+
pending.reject(new BrowserError("Browser was destroyed.", "invalid_state"));
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
assertActive() {
|
|
296
|
+
if (this.destroyed) throw new BrowserError("Browser was destroyed.", "invalid_state");
|
|
297
|
+
}
|
|
298
|
+
resolvePendingAcquire() {
|
|
299
|
+
const pending = this.pendingAcquire;
|
|
300
|
+
if (pending === void 0) return;
|
|
301
|
+
this.pendingAcquire = void 0;
|
|
302
|
+
if (pending.abort !== void 0) {
|
|
303
|
+
pending.abortSignal?.removeEventListener("abort", pending.abort);
|
|
304
|
+
}
|
|
305
|
+
pending.resolve();
|
|
306
|
+
}
|
|
307
|
+
};
|
|
308
|
+
var BrowserHumanControlLeaseImpl = class {
|
|
309
|
+
id = randomUUID2();
|
|
310
|
+
ownerId;
|
|
311
|
+
owner;
|
|
312
|
+
expiration = 0;
|
|
313
|
+
timer;
|
|
314
|
+
released = false;
|
|
315
|
+
constructor(options) {
|
|
316
|
+
this.owner = options.owner;
|
|
317
|
+
this.ownerId = options.ownerId;
|
|
318
|
+
this.resetExpiration(options.leaseTimeoutMs);
|
|
319
|
+
}
|
|
320
|
+
get expiresAt() {
|
|
321
|
+
return new Date(this.expiration).toISOString();
|
|
322
|
+
}
|
|
323
|
+
renew(options) {
|
|
324
|
+
if (this.released) {
|
|
325
|
+
throw new BrowserError("Browser human control lease is no longer active.", "invalid_state");
|
|
326
|
+
}
|
|
327
|
+
return this.owner.renewLease(this, options);
|
|
328
|
+
}
|
|
329
|
+
release() {
|
|
330
|
+
this.owner.releaseLease(this);
|
|
331
|
+
}
|
|
332
|
+
markReleased() {
|
|
333
|
+
if (this.released) return;
|
|
334
|
+
this.released = true;
|
|
335
|
+
if (this.timer !== void 0) clearTimeout(this.timer);
|
|
336
|
+
this.timer = void 0;
|
|
337
|
+
}
|
|
338
|
+
resetExpiration(leaseTimeoutMs) {
|
|
339
|
+
if (this.timer !== void 0) clearTimeout(this.timer);
|
|
340
|
+
this.expiration = Date.now() + leaseTimeoutMs;
|
|
341
|
+
this.timer = setTimeout(() => this.release(), leaseTimeoutMs);
|
|
342
|
+
this.timer.unref?.();
|
|
343
|
+
}
|
|
344
|
+
async [Symbol.asyncDispose]() {
|
|
345
|
+
this.release();
|
|
346
|
+
}
|
|
347
|
+
};
|
|
348
|
+
function validateAcquireOptions(options) {
|
|
349
|
+
if (!isRecord(options)) throw new TypeError("options must be an object.");
|
|
350
|
+
if (typeof options.ownerId !== "string" || options.ownerId.length === 0) {
|
|
351
|
+
throw new TypeError("ownerId must be a non-empty string.");
|
|
352
|
+
}
|
|
353
|
+
assertPositiveSafeInteger(options.leaseTimeoutMs, "leaseTimeoutMs");
|
|
354
|
+
}
|
|
355
|
+
function assertPositiveSafeInteger(value, name) {
|
|
356
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
357
|
+
throw new RangeError(`${name} must be a positive safe integer.`);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
function isRecord(value) {
|
|
361
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// src/docker-browser.ts
|
|
365
|
+
var cdpPort = 9222;
|
|
366
|
+
var noVncPort = 6080;
|
|
367
|
+
var browserSchema = "1";
|
|
368
|
+
var defaultSharedMemoryMb = 1024;
|
|
369
|
+
var seccompProfilePath = fileURLToPath(
|
|
370
|
+
new URL("../security/seccomp_profile.json", import.meta.url)
|
|
371
|
+
);
|
|
372
|
+
var DockerBrowserClient = class {
|
|
373
|
+
sandboxClient;
|
|
374
|
+
image;
|
|
375
|
+
constructor(options) {
|
|
376
|
+
if (!isRecord2(options)) throw new TypeError("options must be an object.");
|
|
377
|
+
if (!isSandboxClient(options.sandboxClient)) {
|
|
378
|
+
throw new TypeError("sandboxClient must be a DockerSandboxClient.");
|
|
379
|
+
}
|
|
380
|
+
assertNonEmptyString(options.image, "image");
|
|
381
|
+
this.sandboxClient = options.sandboxClient;
|
|
382
|
+
this.image = options.image;
|
|
383
|
+
}
|
|
384
|
+
async pullImage(options = {}) {
|
|
385
|
+
assertOptionsObject(options);
|
|
386
|
+
let pullOptions = { image: this.image };
|
|
387
|
+
if (options.abortSignal !== void 0) {
|
|
388
|
+
pullOptions = { ...pullOptions, abortSignal: options.abortSignal };
|
|
389
|
+
}
|
|
390
|
+
await this.sandboxClient.pullImage(pullOptions);
|
|
391
|
+
}
|
|
392
|
+
async createBrowser(options) {
|
|
393
|
+
validateCreateOptions(options);
|
|
394
|
+
options.abortSignal?.throwIfAborted();
|
|
395
|
+
const resources = {
|
|
396
|
+
...options.resources,
|
|
397
|
+
sharedMemoryMb: options.resources?.sharedMemoryMb ?? defaultSharedMemoryMb
|
|
398
|
+
};
|
|
399
|
+
let sandboxOptions = {
|
|
400
|
+
image: this.image,
|
|
401
|
+
workdir: "/workspace",
|
|
402
|
+
workspace: options.workspace,
|
|
403
|
+
network: { mode: "bridge", ports: [cdpPort, noVncPort] },
|
|
404
|
+
user: "pwuser",
|
|
405
|
+
labels: {
|
|
406
|
+
"anvia.browser.schema": browserSchema
|
|
407
|
+
},
|
|
408
|
+
resources,
|
|
409
|
+
security: {
|
|
410
|
+
noNewPrivileges: true,
|
|
411
|
+
dropCapabilities: ["ALL"],
|
|
412
|
+
addCapabilities: ["SYS_CHROOT"],
|
|
413
|
+
seccompProfile: { type: "path", path: seccompProfilePath }
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
if (options.id !== void 0) sandboxOptions = { ...sandboxOptions, id: options.id };
|
|
417
|
+
if (options.runtime !== void 0) {
|
|
418
|
+
sandboxOptions = { ...sandboxOptions, runtime: options.runtime };
|
|
419
|
+
}
|
|
420
|
+
if (options.abortSignal !== void 0) {
|
|
421
|
+
sandboxOptions = { ...sandboxOptions, abortSignal: options.abortSignal };
|
|
422
|
+
}
|
|
423
|
+
const sandbox = await this.sandboxClient.createSandbox(sandboxOptions);
|
|
424
|
+
try {
|
|
425
|
+
await configureBrowser(sandbox, options);
|
|
426
|
+
await startBrowserServices(sandbox, options.abortSignal);
|
|
427
|
+
return new DockerBrowserHandle(sandbox);
|
|
428
|
+
} catch (error) {
|
|
429
|
+
await sandbox.destroy().catch(() => void 0);
|
|
430
|
+
throw new BrowserError("Unable to configure the browser sandbox.", "startup_failed", {
|
|
431
|
+
cause: error
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
async resumeBrowser(options) {
|
|
436
|
+
assertOptionsObject(options);
|
|
437
|
+
assertNonEmptyString(options.id, "id");
|
|
438
|
+
options.abortSignal?.throwIfAborted();
|
|
439
|
+
const sandbox = await this.sandboxClient.resumeSandbox(options);
|
|
440
|
+
try {
|
|
441
|
+
await assertBrowserImage(sandbox, options.abortSignal);
|
|
442
|
+
await startBrowserServices(sandbox, options.abortSignal);
|
|
443
|
+
return new DockerBrowserHandle(sandbox);
|
|
444
|
+
} catch (error) {
|
|
445
|
+
await sandbox.stop().catch(() => void 0);
|
|
446
|
+
throw new BrowserError("Unable to resume browser services.", "startup_failed", {
|
|
447
|
+
cause: error
|
|
448
|
+
});
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
};
|
|
452
|
+
var DockerBrowserHandle = class {
|
|
453
|
+
id;
|
|
454
|
+
sandbox;
|
|
455
|
+
desktop;
|
|
456
|
+
control = new BrowserControlState();
|
|
457
|
+
connections = /* @__PURE__ */ new Set();
|
|
458
|
+
constructor(sandbox) {
|
|
459
|
+
this.sandbox = sandbox;
|
|
460
|
+
this.id = sandbox.id;
|
|
461
|
+
this.desktop = Object.freeze({
|
|
462
|
+
protocol: "novnc",
|
|
463
|
+
containerPort: noVncPort,
|
|
464
|
+
control: this.control
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
get state() {
|
|
468
|
+
return this.sandbox.state;
|
|
469
|
+
}
|
|
470
|
+
inspector(options) {
|
|
471
|
+
return this.sandbox.inspector(options);
|
|
472
|
+
}
|
|
473
|
+
async waitUntilReady(options) {
|
|
474
|
+
assertOptionsObject(options);
|
|
475
|
+
assertPositiveSafeInteger2(options.timeoutMs, "timeoutMs");
|
|
476
|
+
this.assertRunning();
|
|
477
|
+
const timeout = AbortSignal.timeout(options.timeoutMs);
|
|
478
|
+
const abortSignal = options.abortSignal === void 0 ? timeout : AbortSignal.any([options.abortSignal, timeout]);
|
|
479
|
+
try {
|
|
480
|
+
await Promise.all([
|
|
481
|
+
this.sandbox.runtime.waitForPort({
|
|
482
|
+
containerPort: cdpPort,
|
|
483
|
+
timeoutMs: options.timeoutMs,
|
|
484
|
+
abortSignal
|
|
485
|
+
}),
|
|
486
|
+
this.sandbox.runtime.waitForPort({
|
|
487
|
+
containerPort: noVncPort,
|
|
488
|
+
timeoutMs: options.timeoutMs,
|
|
489
|
+
abortSignal
|
|
490
|
+
})
|
|
491
|
+
]);
|
|
492
|
+
await Promise.all([
|
|
493
|
+
assertHttpReady(`${endpointFor(this.sandbox, cdpPort)}/json/version`, abortSignal),
|
|
494
|
+
assertHttpReady(`${endpointFor(this.sandbox, noVncPort)}/vnc.html`, abortSignal)
|
|
495
|
+
]);
|
|
496
|
+
} catch (error) {
|
|
497
|
+
if (options.abortSignal?.aborted) options.abortSignal.throwIfAborted();
|
|
498
|
+
throw new BrowserError("Browser did not become ready before the timeout.", "not_ready", {
|
|
499
|
+
cause: error
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
async connect(options = {}) {
|
|
504
|
+
assertOptionsObject(options);
|
|
505
|
+
this.assertRunning();
|
|
506
|
+
try {
|
|
507
|
+
let connectOptions = {
|
|
508
|
+
endpointUrl: endpointFor(this.sandbox, cdpPort),
|
|
509
|
+
control: this.control
|
|
510
|
+
};
|
|
511
|
+
if (options.abortSignal !== void 0) {
|
|
512
|
+
connectOptions = { ...connectOptions, abortSignal: options.abortSignal };
|
|
513
|
+
}
|
|
514
|
+
const connection = await connectPlaywrightBrowser(connectOptions);
|
|
515
|
+
this.connections.add(connection);
|
|
516
|
+
return connection;
|
|
517
|
+
} catch (error) {
|
|
518
|
+
if (options.abortSignal?.aborted) options.abortSignal.throwIfAborted();
|
|
519
|
+
throw new BrowserError("Unable to connect to Chromium over CDP.", "not_ready", {
|
|
520
|
+
cause: error
|
|
521
|
+
});
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
async stop(options = {}) {
|
|
525
|
+
assertOptionsObject(options);
|
|
526
|
+
await this.disconnectAll();
|
|
527
|
+
await this.sandbox.stop(options);
|
|
528
|
+
}
|
|
529
|
+
async destroy() {
|
|
530
|
+
this.control.destroy();
|
|
531
|
+
await this.disconnectAll();
|
|
532
|
+
await this.sandbox.destroy();
|
|
533
|
+
}
|
|
534
|
+
async [Symbol.asyncDispose]() {
|
|
535
|
+
await this.destroy();
|
|
536
|
+
}
|
|
537
|
+
assertRunning() {
|
|
538
|
+
if (this.state !== "running") {
|
|
539
|
+
throw new BrowserError(`Browser is not running: ${this.state}`, "invalid_state");
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async disconnectAll() {
|
|
543
|
+
const connections = [...this.connections];
|
|
544
|
+
this.connections.clear();
|
|
545
|
+
const results = await Promise.allSettled(
|
|
546
|
+
connections.map((connection) => connection.disconnect())
|
|
547
|
+
);
|
|
548
|
+
const failures = results.filter((result) => result.status === "rejected").map((result) => result.reason);
|
|
549
|
+
if (failures.length > 0) {
|
|
550
|
+
throw new AggregateError(failures, "Unable to disconnect browser automation clients.");
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
};
|
|
554
|
+
async function configureBrowser(sandbox, options) {
|
|
555
|
+
let execOptions = {
|
|
556
|
+
command: "/usr/local/bin/anvia-browser-configure",
|
|
557
|
+
input: JSON.stringify({
|
|
558
|
+
password: options.desktop.password,
|
|
559
|
+
width: options.desktop.viewport.width,
|
|
560
|
+
height: options.desktop.viewport.height
|
|
561
|
+
})
|
|
562
|
+
};
|
|
563
|
+
if (options.abortSignal !== void 0) {
|
|
564
|
+
execOptions = { ...execOptions, abortSignal: options.abortSignal };
|
|
565
|
+
}
|
|
566
|
+
const result = await sandbox.runtime.exec(execOptions);
|
|
567
|
+
if (result.status !== "exited" || result.exitCode !== 0) {
|
|
568
|
+
throw new Error("Browser image rejected its runtime configuration.");
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
async function assertBrowserImage(sandbox, abortSignal) {
|
|
572
|
+
let execOptions = {
|
|
573
|
+
command: "/usr/local/bin/anvia-browser-version"
|
|
574
|
+
};
|
|
575
|
+
if (abortSignal !== void 0) execOptions = { ...execOptions, abortSignal };
|
|
576
|
+
const result = await sandbox.runtime.exec(execOptions);
|
|
577
|
+
if (result.status !== "exited" || result.exitCode !== 0 || new TextDecoder("utf-8", { fatal: true }).decode(result.stdout).trim() !== browserSchema) {
|
|
578
|
+
throw new Error("Sandbox does not contain a compatible Anvia browser image.");
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async function startBrowserServices(sandbox, abortSignal) {
|
|
582
|
+
let startOptions = {
|
|
583
|
+
command: "/usr/local/bin/anvia-browser-start"
|
|
584
|
+
};
|
|
585
|
+
if (abortSignal !== void 0) startOptions = { ...startOptions, abortSignal };
|
|
586
|
+
await sandbox.runtime.startProcess(startOptions);
|
|
587
|
+
}
|
|
588
|
+
async function assertHttpReady(url, abortSignal) {
|
|
589
|
+
while (true) {
|
|
590
|
+
abortSignal.throwIfAborted();
|
|
591
|
+
try {
|
|
592
|
+
const response = await fetch(url, { signal: abortSignal, redirect: "error" });
|
|
593
|
+
if (response.ok) {
|
|
594
|
+
await response.body?.cancel();
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
await response.body?.cancel();
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (abortSignal.aborted) throw abortSignal.reason ?? error;
|
|
600
|
+
}
|
|
601
|
+
await waitForRetry(abortSignal);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
async function waitForRetry(abortSignal) {
|
|
605
|
+
await new Promise((resolve, reject) => {
|
|
606
|
+
const timeout = setTimeout(finish, 50);
|
|
607
|
+
const abort = () => finish(abortSignal.reason ?? new DOMException("Aborted", "AbortError"));
|
|
608
|
+
abortSignal.addEventListener("abort", abort, { once: true });
|
|
609
|
+
function finish(error) {
|
|
610
|
+
clearTimeout(timeout);
|
|
611
|
+
abortSignal.removeEventListener("abort", abort);
|
|
612
|
+
if (error === void 0) resolve();
|
|
613
|
+
else reject(error);
|
|
614
|
+
}
|
|
615
|
+
});
|
|
616
|
+
}
|
|
617
|
+
function endpointFor(sandbox, containerPort) {
|
|
618
|
+
const published = publishedPort(sandbox, containerPort);
|
|
619
|
+
return `http://${hostForUrl(published.host)}:${published.hostPort}`;
|
|
620
|
+
}
|
|
621
|
+
function publishedPort(sandbox, containerPort) {
|
|
622
|
+
const port = sandbox.runtime.publishedPorts.find(
|
|
623
|
+
(candidate) => candidate.containerPort === containerPort && candidate.protocol === "tcp"
|
|
624
|
+
);
|
|
625
|
+
if (port === void 0) {
|
|
626
|
+
throw new BrowserError(`Browser port is not published: ${containerPort}`, "invalid_state");
|
|
627
|
+
}
|
|
628
|
+
return port;
|
|
629
|
+
}
|
|
630
|
+
function hostForUrl(host) {
|
|
631
|
+
return host.includes(":") ? `[${host}]` : host;
|
|
632
|
+
}
|
|
633
|
+
function validateCreateOptions(options) {
|
|
634
|
+
assertOptionsObject(options);
|
|
635
|
+
if (options.id !== void 0) assertNonEmptyString(options.id, "id");
|
|
636
|
+
if (!isRecord2(options.workspace)) throw new TypeError("workspace must be an object.");
|
|
637
|
+
if (!isRecord2(options.network) || options.network.mode !== "bridge") {
|
|
638
|
+
throw new TypeError('network must be { mode: "bridge" }.');
|
|
639
|
+
}
|
|
640
|
+
if (!isRecord2(options.desktop) || options.desktop.protocol !== "novnc") {
|
|
641
|
+
throw new TypeError('desktop must use protocol: "novnc".');
|
|
642
|
+
}
|
|
643
|
+
if (!/^[\x20-\x7e]{8}$/.test(options.desktop.password)) {
|
|
644
|
+
throw new TypeError("desktop.password must contain exactly 8 printable ASCII characters.");
|
|
645
|
+
}
|
|
646
|
+
if (!isRecord2(options.desktop.viewport)) {
|
|
647
|
+
throw new TypeError("desktop.viewport must be an object.");
|
|
648
|
+
}
|
|
649
|
+
assertIntegerInRange(options.desktop.viewport.width, "desktop.viewport.width", 640, 3840);
|
|
650
|
+
assertIntegerInRange(options.desktop.viewport.height, "desktop.viewport.height", 480, 2160);
|
|
651
|
+
if (options.runtime?.maxProcesses !== void 0 && options.runtime.maxProcesses < 1) {
|
|
652
|
+
throw new RangeError("runtime.maxProcesses must allow the browser service process.");
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
function assertIntegerInRange(value, name, min, max) {
|
|
656
|
+
if (!Number.isSafeInteger(value) || value < min || value > max) {
|
|
657
|
+
throw new RangeError(`${name} must be a safe integer between ${min} and ${max}.`);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
function assertPositiveSafeInteger2(value, name) {
|
|
661
|
+
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
662
|
+
throw new RangeError(`${name} must be a positive safe integer.`);
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
function assertNonEmptyString(value, name) {
|
|
666
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
667
|
+
throw new TypeError(`${name} must be a non-empty string.`);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
function assertOptionsObject(value) {
|
|
671
|
+
if (!isRecord2(value)) throw new TypeError("options must be an object.");
|
|
672
|
+
}
|
|
673
|
+
function isSandboxClient(value) {
|
|
674
|
+
return isRecord2(value) && typeof value.pullImage === "function" && typeof value.createSandbox === "function" && typeof value.resumeSandbox === "function";
|
|
675
|
+
}
|
|
676
|
+
function isRecord2(value) {
|
|
677
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
// src/tools.ts
|
|
681
|
+
import { createTool, ToolOutput } from "@anvia/core/tool";
|
|
682
|
+
import { z } from "zod";
|
|
683
|
+
var targetSchema = z.discriminatedUnion("by", [
|
|
684
|
+
z.object({
|
|
685
|
+
by: z.literal("role"),
|
|
686
|
+
role: z.string().min(1),
|
|
687
|
+
name: z.string().optional(),
|
|
688
|
+
exact: z.boolean().optional()
|
|
689
|
+
}),
|
|
690
|
+
z.object({ by: z.literal("text"), text: z.string().min(1), exact: z.boolean().optional() }),
|
|
691
|
+
z.object({ by: z.literal("label"), label: z.string().min(1), exact: z.boolean().optional() }),
|
|
692
|
+
z.object({
|
|
693
|
+
by: z.literal("placeholder"),
|
|
694
|
+
placeholder: z.string().min(1),
|
|
695
|
+
exact: z.boolean().optional()
|
|
696
|
+
}),
|
|
697
|
+
z.object({ by: z.literal("test-id"), testId: z.string().min(1) }),
|
|
698
|
+
z.object({ by: z.literal("css"), selector: z.string().min(1) })
|
|
699
|
+
]);
|
|
700
|
+
var noInput = z.object({});
|
|
701
|
+
var tabInput = z.object({ tabId: z.string().uuid() });
|
|
702
|
+
var navigateInput = z.object({
|
|
703
|
+
url: z.url(),
|
|
704
|
+
waitUntil: z.enum(["commit", "domcontentloaded", "load", "networkidle"]).optional()
|
|
705
|
+
});
|
|
706
|
+
var clickInput = z.object({ target: targetSchema });
|
|
707
|
+
var typeInput = z.object({ target: targetSchema, text: z.string() });
|
|
708
|
+
var pressKeyInput = z.object({ key: z.string().min(1).max(100) });
|
|
709
|
+
var screenshotInput = z.object({});
|
|
710
|
+
var defaultActionTimeoutMs = 1e4;
|
|
711
|
+
var defaultNavigationTimeoutMs = 3e4;
|
|
712
|
+
var defaultSnapshotMaxChars = 5e4;
|
|
713
|
+
var maxTimeoutMs = 3e5;
|
|
714
|
+
var maxSnapshotChars = 2e5;
|
|
715
|
+
function createBrowserTools(options) {
|
|
716
|
+
validateFactoryOptions(options);
|
|
717
|
+
const connection = asConnection(options.connection);
|
|
718
|
+
const policy = snapshotNavigationPolicy(options.navigation);
|
|
719
|
+
connection.setNavigationPolicy(policy);
|
|
720
|
+
const limits = {
|
|
721
|
+
actionTimeoutMs: options.limits?.actionTimeoutMs ?? defaultActionTimeoutMs,
|
|
722
|
+
navigationTimeoutMs: options.limits?.navigationTimeoutMs ?? defaultNavigationTimeoutMs,
|
|
723
|
+
snapshotMaxChars: options.limits?.snapshotMaxChars ?? defaultSnapshotMaxChars
|
|
724
|
+
};
|
|
725
|
+
const tools = options.tools.map((name) => {
|
|
726
|
+
switch (name) {
|
|
727
|
+
case "browser_list_tabs":
|
|
728
|
+
return createListTabsTool(connection);
|
|
729
|
+
case "browser_open_tab":
|
|
730
|
+
return createOpenTabTool(connection);
|
|
731
|
+
case "browser_select_tab":
|
|
732
|
+
return createSelectTabTool(connection);
|
|
733
|
+
case "browser_close_tab":
|
|
734
|
+
return createCloseTabTool(connection);
|
|
735
|
+
case "browser_navigate":
|
|
736
|
+
return createNavigateTool(connection, policy, limits.navigationTimeoutMs);
|
|
737
|
+
case "browser_snapshot":
|
|
738
|
+
return createSnapshotTool(connection, limits.actionTimeoutMs, limits.snapshotMaxChars);
|
|
739
|
+
case "browser_click":
|
|
740
|
+
return createClickTool(connection, limits.actionTimeoutMs);
|
|
741
|
+
case "browser_type":
|
|
742
|
+
return createTypeTool(connection, limits.actionTimeoutMs);
|
|
743
|
+
case "browser_press_key":
|
|
744
|
+
return createPressKeyTool(connection);
|
|
745
|
+
case "browser_screenshot":
|
|
746
|
+
return createScreenshotTool(connection);
|
|
747
|
+
default:
|
|
748
|
+
return assertNever(name);
|
|
749
|
+
}
|
|
750
|
+
});
|
|
751
|
+
return Object.freeze(tools);
|
|
752
|
+
}
|
|
753
|
+
function createListTabsTool(connection) {
|
|
754
|
+
return createTool({
|
|
755
|
+
name: "browser_list_tabs",
|
|
756
|
+
description: "List Chromium tabs and identify the tab selected for subsequent browser tools.",
|
|
757
|
+
inputSchema: noInput,
|
|
758
|
+
execute: async () => ({ tabs: await connection.listTabs() })
|
|
759
|
+
});
|
|
760
|
+
}
|
|
761
|
+
function createOpenTabTool(connection) {
|
|
762
|
+
return createTool({
|
|
763
|
+
name: "browser_open_tab",
|
|
764
|
+
description: "Open and select a new blank Chromium tab.",
|
|
765
|
+
inputSchema: noInput,
|
|
766
|
+
execute: async (_args, context) => connection.runAction(context.abortSignal, async () => {
|
|
767
|
+
const page = await connection.openTab();
|
|
768
|
+
return tabResult(connection, page);
|
|
769
|
+
})
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
function createSelectTabTool(connection) {
|
|
773
|
+
return createTool({
|
|
774
|
+
name: "browser_select_tab",
|
|
775
|
+
description: "Select an existing Chromium tab by its browser tab ID.",
|
|
776
|
+
inputSchema: tabInput,
|
|
777
|
+
execute: async ({ tabId }, context) => connection.runAction(
|
|
778
|
+
context.abortSignal,
|
|
779
|
+
async () => tabResult(connection, connection.selectTab(tabId))
|
|
780
|
+
)
|
|
781
|
+
});
|
|
782
|
+
}
|
|
783
|
+
function createCloseTabTool(connection) {
|
|
784
|
+
return createTool({
|
|
785
|
+
name: "browser_close_tab",
|
|
786
|
+
description: "Close an existing Chromium tab by its browser tab ID.",
|
|
787
|
+
inputSchema: tabInput,
|
|
788
|
+
execute: async ({ tabId }, context) => connection.runAction(context.abortSignal, async () => {
|
|
789
|
+
await connection.closeTab(tabId);
|
|
790
|
+
return { closedTabId: tabId, tabs: await connection.tabSummaries() };
|
|
791
|
+
})
|
|
792
|
+
});
|
|
793
|
+
}
|
|
794
|
+
function createNavigateTool(connection, policy, timeoutMs) {
|
|
795
|
+
return createTool({
|
|
796
|
+
name: "browser_navigate",
|
|
797
|
+
description: "Navigate the selected Chromium tab to an allowed HTTP or HTTPS URL.",
|
|
798
|
+
inputSchema: navigateInput,
|
|
799
|
+
execute: async ({ url, waitUntil }, context) => connection.runAction(context.abortSignal, async () => {
|
|
800
|
+
assertNavigationAllowed(url, policy);
|
|
801
|
+
const page = connection.selectedPage();
|
|
802
|
+
await page.goto(url, {
|
|
803
|
+
timeout: timeoutMs,
|
|
804
|
+
waitUntil: waitUntil ?? "load"
|
|
805
|
+
});
|
|
806
|
+
assertNavigationAllowed(page.url(), policy);
|
|
807
|
+
return tabResult(connection, page);
|
|
808
|
+
})
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
function createSnapshotTool(connection, timeoutMs, maxChars) {
|
|
812
|
+
return createTool({
|
|
813
|
+
name: "browser_snapshot",
|
|
814
|
+
description: "Inspect the selected tab using a bounded ARIA accessibility snapshot.",
|
|
815
|
+
inputSchema: noInput,
|
|
816
|
+
execute: async (_args, context) => connection.runAction(context.abortSignal, async () => {
|
|
817
|
+
const page = connection.selectedPage();
|
|
818
|
+
const snapshot = await page.locator("body").ariaSnapshot({ timeout: timeoutMs });
|
|
819
|
+
const truncated = snapshot.length > maxChars;
|
|
820
|
+
return {
|
|
821
|
+
...await tabResult(connection, page),
|
|
822
|
+
snapshot: truncated ? snapshot.slice(0, maxChars) : snapshot,
|
|
823
|
+
truncated
|
|
824
|
+
};
|
|
825
|
+
})
|
|
826
|
+
});
|
|
827
|
+
}
|
|
828
|
+
function createClickTool(connection, timeoutMs) {
|
|
829
|
+
return createTool({
|
|
830
|
+
name: "browser_click",
|
|
831
|
+
description: "Click one strictly matched element in the selected tab.",
|
|
832
|
+
inputSchema: clickInput,
|
|
833
|
+
execute: async ({ target }, context) => connection.runAction(context.abortSignal, async () => {
|
|
834
|
+
const page = connection.selectedPage();
|
|
835
|
+
await locatorFor(page, target).click({ timeout: timeoutMs });
|
|
836
|
+
return tabResult(connection, page);
|
|
837
|
+
})
|
|
838
|
+
});
|
|
839
|
+
}
|
|
840
|
+
function createTypeTool(connection, timeoutMs) {
|
|
841
|
+
return createTool({
|
|
842
|
+
name: "browser_type",
|
|
843
|
+
description: "Replace the value of one strictly matched editable element.",
|
|
844
|
+
inputSchema: typeInput,
|
|
845
|
+
execute: async ({ target, text }, context) => connection.runAction(context.abortSignal, async () => {
|
|
846
|
+
const page = connection.selectedPage();
|
|
847
|
+
await locatorFor(page, target).fill(text, { timeout: timeoutMs });
|
|
848
|
+
return tabResult(connection, page);
|
|
849
|
+
})
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
function createPressKeyTool(connection) {
|
|
853
|
+
return createTool({
|
|
854
|
+
name: "browser_press_key",
|
|
855
|
+
description: "Press an explicit keyboard key or key combination in the selected tab.",
|
|
856
|
+
inputSchema: pressKeyInput,
|
|
857
|
+
execute: async ({ key }, context) => connection.runAction(context.abortSignal, async () => {
|
|
858
|
+
const page = connection.selectedPage();
|
|
859
|
+
await page.keyboard.press(key);
|
|
860
|
+
return tabResult(connection, page);
|
|
861
|
+
})
|
|
862
|
+
});
|
|
863
|
+
}
|
|
864
|
+
function createScreenshotTool(connection) {
|
|
865
|
+
return createTool({
|
|
866
|
+
name: "browser_screenshot",
|
|
867
|
+
description: "Capture the visible viewport of the selected Chromium tab as PNG.",
|
|
868
|
+
inputSchema: screenshotInput,
|
|
869
|
+
execute: async (_args, context) => connection.runAction(context.abortSignal, async () => {
|
|
870
|
+
const page = connection.selectedPage();
|
|
871
|
+
const png = await page.screenshot({ type: "png", fullPage: false });
|
|
872
|
+
const metadata = await tabResult(connection, page);
|
|
873
|
+
return ToolOutput.content([
|
|
874
|
+
{ type: "text", text: JSON.stringify(metadata) },
|
|
875
|
+
{
|
|
876
|
+
type: "file",
|
|
877
|
+
data: { type: "data", data: png.toString("base64") },
|
|
878
|
+
mediaType: "image/png",
|
|
879
|
+
filename: "browser-screenshot.png"
|
|
880
|
+
}
|
|
881
|
+
]);
|
|
882
|
+
})
|
|
883
|
+
});
|
|
884
|
+
}
|
|
885
|
+
async function tabResult(connection, page) {
|
|
886
|
+
return {
|
|
887
|
+
tabId: connection.idFor(page),
|
|
888
|
+
title: await page.title(),
|
|
889
|
+
url: page.url()
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
function locatorFor(page, target) {
|
|
893
|
+
switch (target.by) {
|
|
894
|
+
case "role": {
|
|
895
|
+
const options = {};
|
|
896
|
+
if (target.name !== void 0) options.name = target.name;
|
|
897
|
+
if (target.exact !== void 0) options.exact = target.exact;
|
|
898
|
+
return page.getByRole(target.role, options);
|
|
899
|
+
}
|
|
900
|
+
case "text": {
|
|
901
|
+
const options = {};
|
|
902
|
+
if (target.exact !== void 0) options.exact = target.exact;
|
|
903
|
+
return page.getByText(target.text, options);
|
|
904
|
+
}
|
|
905
|
+
case "label": {
|
|
906
|
+
const options = {};
|
|
907
|
+
if (target.exact !== void 0) options.exact = target.exact;
|
|
908
|
+
return page.getByLabel(target.label, options);
|
|
909
|
+
}
|
|
910
|
+
case "placeholder": {
|
|
911
|
+
const options = {};
|
|
912
|
+
if (target.exact !== void 0) options.exact = target.exact;
|
|
913
|
+
return page.getByPlaceholder(target.placeholder, options);
|
|
914
|
+
}
|
|
915
|
+
case "test-id":
|
|
916
|
+
return page.getByTestId(target.testId);
|
|
917
|
+
case "css":
|
|
918
|
+
return page.locator(target.selector);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
function snapshotNavigationPolicy(policy) {
|
|
922
|
+
if (!isRecord3(policy)) throw new TypeError("navigation must be an object.");
|
|
923
|
+
if (policy.mode === "allow-all-http") return Object.freeze({ mode: "allow-all-http" });
|
|
924
|
+
if (policy.mode !== "origins" || !Array.isArray(policy.origins) || policy.origins.length === 0) {
|
|
925
|
+
throw new TypeError(
|
|
926
|
+
"navigation must explicitly allow all HTTP URLs or a non-empty origin list."
|
|
927
|
+
);
|
|
928
|
+
}
|
|
929
|
+
const origins = policy.origins.map((origin) => normalizeOrigin(origin));
|
|
930
|
+
if (new Set(origins).size !== origins.length) {
|
|
931
|
+
throw new TypeError("navigation.origins contains a duplicate origin.");
|
|
932
|
+
}
|
|
933
|
+
return Object.freeze({ mode: "origins", origins: Object.freeze(origins) });
|
|
934
|
+
}
|
|
935
|
+
function normalizeOrigin(value) {
|
|
936
|
+
if (typeof value !== "string") throw new TypeError("navigation origin must be a string.");
|
|
937
|
+
const url = parseHttpUrl(value);
|
|
938
|
+
if (value !== url.origin) {
|
|
939
|
+
throw new TypeError(`navigation origin must not contain a path, query, or fragment: ${value}`);
|
|
940
|
+
}
|
|
941
|
+
return url.origin;
|
|
942
|
+
}
|
|
943
|
+
function assertNavigationAllowed(value, policy) {
|
|
944
|
+
if (!isNavigationAllowed2(value, policy)) {
|
|
945
|
+
throw new BrowserError(`Browser navigation is blocked: ${value}`, "navigation_blocked");
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
function isNavigationAllowed2(value, policy) {
|
|
949
|
+
let url;
|
|
950
|
+
try {
|
|
951
|
+
url = parseHttpUrl(value);
|
|
952
|
+
} catch {
|
|
953
|
+
return false;
|
|
954
|
+
}
|
|
955
|
+
return policy.mode === "allow-all-http" || policy.origins.includes(url.origin);
|
|
956
|
+
}
|
|
957
|
+
function parseHttpUrl(value) {
|
|
958
|
+
const url = new URL(value);
|
|
959
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
960
|
+
throw new TypeError("Browser navigation only supports HTTP and HTTPS URLs.");
|
|
961
|
+
}
|
|
962
|
+
if (url.username.length > 0 || url.password.length > 0) {
|
|
963
|
+
throw new TypeError("Browser navigation URLs must not include credentials.");
|
|
964
|
+
}
|
|
965
|
+
return url;
|
|
966
|
+
}
|
|
967
|
+
function validateFactoryOptions(options) {
|
|
968
|
+
if (!isRecord3(options)) throw new TypeError("options must be an object.");
|
|
969
|
+
if (!Array.isArray(options.tools) || options.tools.length === 0) {
|
|
970
|
+
throw new TypeError("tools must be a non-empty array.");
|
|
971
|
+
}
|
|
972
|
+
const seen = /* @__PURE__ */ new Set();
|
|
973
|
+
for (const name of options.tools) {
|
|
974
|
+
if (!isToolName(name)) throw new TypeError(`Unsupported browser tool name: ${name}`);
|
|
975
|
+
if (seen.has(name)) throw new TypeError(`tools contains a duplicate: ${name}`);
|
|
976
|
+
seen.add(name);
|
|
977
|
+
}
|
|
978
|
+
snapshotNavigationPolicy(options.navigation);
|
|
979
|
+
if (options.limits !== void 0 && !isRecord3(options.limits)) {
|
|
980
|
+
throw new TypeError("limits must be an object.");
|
|
981
|
+
}
|
|
982
|
+
assertOptionalBoundedInteger(options.limits?.actionTimeoutMs, "actionTimeoutMs", 1, maxTimeoutMs);
|
|
983
|
+
assertOptionalBoundedInteger(
|
|
984
|
+
options.limits?.navigationTimeoutMs,
|
|
985
|
+
"navigationTimeoutMs",
|
|
986
|
+
1,
|
|
987
|
+
maxTimeoutMs
|
|
988
|
+
);
|
|
989
|
+
assertOptionalBoundedInteger(
|
|
990
|
+
options.limits?.snapshotMaxChars,
|
|
991
|
+
"snapshotMaxChars",
|
|
992
|
+
1,
|
|
993
|
+
maxSnapshotChars
|
|
994
|
+
);
|
|
995
|
+
}
|
|
996
|
+
function assertOptionalBoundedInteger(value, name, min, max) {
|
|
997
|
+
if (value !== void 0 && (!Number.isSafeInteger(value) || value < min || value > max)) {
|
|
998
|
+
throw new RangeError(`${name} must be a safe integer between ${min} and ${max}.`);
|
|
999
|
+
}
|
|
1000
|
+
}
|
|
1001
|
+
function isToolName(value) {
|
|
1002
|
+
return typeof value === "string" && [
|
|
1003
|
+
"browser_list_tabs",
|
|
1004
|
+
"browser_open_tab",
|
|
1005
|
+
"browser_select_tab",
|
|
1006
|
+
"browser_close_tab",
|
|
1007
|
+
"browser_navigate",
|
|
1008
|
+
"browser_snapshot",
|
|
1009
|
+
"browser_click",
|
|
1010
|
+
"browser_type",
|
|
1011
|
+
"browser_press_key",
|
|
1012
|
+
"browser_screenshot"
|
|
1013
|
+
].includes(value);
|
|
1014
|
+
}
|
|
1015
|
+
function isRecord3(value) {
|
|
1016
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1017
|
+
}
|
|
1018
|
+
function assertNever(value) {
|
|
1019
|
+
throw new TypeError(`Unsupported browser tool: ${value}`);
|
|
1020
|
+
}
|
|
1021
|
+
export {
|
|
1022
|
+
BrowserError,
|
|
1023
|
+
DockerBrowserClient,
|
|
1024
|
+
createBrowserTools
|
|
1025
|
+
};
|
|
1026
|
+
//# sourceMappingURL=index.js.map
|