182 lines
7.6 KiB
JavaScript
182 lines
7.6 KiB
JavaScript
import { BrowserAccessPolicy } from "./access-policy.js";
|
|
|
|
function boundedString(value, label, maxLength) {
|
|
if (typeof value !== "string" || value.length === 0 || value.length > maxLength || value.includes("\0")) throw new Error(`${label} must contain between 1 and ${maxLength} characters`);
|
|
return value;
|
|
}
|
|
|
|
function boundedInteger(value, fallback, minimum, maximum, label) {
|
|
const result = value ?? fallback;
|
|
if (!Number.isInteger(result) || result < minimum || result > maximum) throw new Error(`${label} must be between ${minimum} and ${maximum}`);
|
|
return result;
|
|
}
|
|
|
|
function pushBounded(array, value, maximum = 200) {
|
|
array.push(value);
|
|
if (array.length > maximum) array.splice(0, array.length - maximum);
|
|
}
|
|
|
|
export class BrowserService {
|
|
constructor({ allowedOrigins, browserFactory, maxSessions = 16, defaultTimeoutMs = 15000 }) {
|
|
this.policy = new BrowserAccessPolicy(allowedOrigins);
|
|
this.browserFactory = browserFactory;
|
|
this.maxSessions = maxSessions;
|
|
this.defaultTimeoutMs = defaultTimeoutMs;
|
|
this.sessions = new Map();
|
|
this.browserPromise = null;
|
|
}
|
|
|
|
async browser() {
|
|
if (!this.browserPromise) this.browserPromise = this.browserFactory();
|
|
return this.browserPromise;
|
|
}
|
|
|
|
async createSession(sessionId) {
|
|
if (this.sessions.has(sessionId)) return this.sessions.get(sessionId);
|
|
if (this.sessions.size >= this.maxSessions) throw new Error("Browser session capacity reached");
|
|
const browser = await this.browser();
|
|
const context = await browser.newContext({
|
|
acceptDownloads: false,
|
|
ignoreHTTPSErrors: false,
|
|
serviceWorkers: "block",
|
|
viewport: { width: 1440, height: 900 }
|
|
});
|
|
await context.route("**/*", async (route) => {
|
|
try { this.policy.assertUrl(route.request().url()); await route.continue(); }
|
|
catch { await route.abort("blockedbyclient"); }
|
|
});
|
|
await context.routeWebSocket("**/*", async (webSocket) => {
|
|
try {
|
|
this.policy.assertWebSocketUrl(webSocket.url());
|
|
webSocket.connectToServer();
|
|
} catch {
|
|
await webSocket.close({ code: 1008, reason: "Origin is not allowed" });
|
|
}
|
|
});
|
|
const page = await context.newPage();
|
|
page.setDefaultTimeout(this.defaultTimeoutMs);
|
|
page.setDefaultNavigationTimeout(this.defaultTimeoutMs);
|
|
const state = { context, page, console: [], network: [], queue: Promise.resolve() };
|
|
page.on("console", (message) => pushBounded(state.console, {
|
|
type: message.type(),
|
|
text: message.text().slice(0, 4096),
|
|
timestamp: new Date().toISOString()
|
|
}));
|
|
page.on("pageerror", (error) => pushBounded(state.console, { type: "pageerror", text: error.message.slice(0, 4096), timestamp: new Date().toISOString() }));
|
|
page.on("requestfailed", (request) => pushBounded(state.network, {
|
|
type: "requestfailed", method: request.method(), url: request.url().slice(0, 4096), error: request.failure()?.errorText ?? "failed"
|
|
}));
|
|
page.on("response", (response) => {
|
|
if (response.status() >= 400) pushBounded(state.network, { type: "http", status: response.status(), url: response.url().slice(0, 4096) });
|
|
});
|
|
this.sessions.set(sessionId, state);
|
|
return state;
|
|
}
|
|
|
|
async run(sessionId, operation) {
|
|
if (typeof sessionId !== "string" || !sessionId) throw new Error("An initialized MCP session is required");
|
|
const state = await this.createSession(sessionId);
|
|
const next = state.queue.then(() => operation(state));
|
|
state.queue = next.catch(() => {});
|
|
return next;
|
|
}
|
|
|
|
navigate(args, context) {
|
|
return this.run(context.sessionId, async ({ page }) => {
|
|
const url = this.policy.assertUrl(boundedString(args.url, "url", 4096), { navigation: true });
|
|
const timeout = boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs");
|
|
await page.goto(url, { waitUntil: "domcontentloaded", timeout });
|
|
return { url: page.url(), title: await page.title() };
|
|
});
|
|
}
|
|
|
|
click(args, context) {
|
|
return this.run(context.sessionId, async ({ page }) => {
|
|
const selector = boundedString(args.selector, "selector", 1024);
|
|
await page.locator(selector).first().click({ timeout: boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs") });
|
|
return { clicked: selector, url: page.url() };
|
|
});
|
|
}
|
|
|
|
fill(args, context) {
|
|
return this.run(context.sessionId, async ({ page }) => {
|
|
const selector = boundedString(args.selector, "selector", 1024);
|
|
const value = boundedString(args.value, "value", 10000);
|
|
await page.locator(selector).first().fill(value, { timeout: boundedInteger(args.timeoutMs, this.defaultTimeoutMs, 100, 30000, "timeoutMs") });
|
|
return { filled: selector, characters: value.length };
|
|
});
|
|
}
|
|
|
|
wait(args, context) {
|
|
return this.run(context.sessionId, async ({ page }) => {
|
|
const milliseconds = boundedInteger(args.milliseconds, 500, 1, 5000, "milliseconds");
|
|
await page.waitForTimeout(milliseconds);
|
|
return { waitedMilliseconds: milliseconds };
|
|
});
|
|
}
|
|
|
|
snapshot(args, context) {
|
|
return this.run(context.sessionId, async ({ page }) => {
|
|
const maxTextCharacters = boundedInteger(args.maxTextCharacters, 50000, 1000, 100000, "maxTextCharacters");
|
|
const snapshot = await page.evaluate(({ textLimit }) => {
|
|
const text = (document.body?.innerText ?? "").slice(0, textLimit);
|
|
const elements = [...document.querySelectorAll("a,button,input,select,textarea,[role]")].slice(0, 200).map((element, index) => ({
|
|
index,
|
|
tag: element.tagName.toLowerCase(),
|
|
role: element.getAttribute("role"),
|
|
name: (element.getAttribute("aria-label") || element.textContent || element.getAttribute("placeholder") || "").trim().slice(0, 300),
|
|
type: element.getAttribute("type"),
|
|
href: element instanceof HTMLAnchorElement ? element.href : null,
|
|
disabled: "disabled" in element ? Boolean(element.disabled) : false
|
|
}));
|
|
return { text, textTruncated: (document.body?.innerText?.length ?? 0) > textLimit, elements };
|
|
}, { textLimit: maxTextCharacters });
|
|
return { url: page.url(), title: await page.title(), ...snapshot };
|
|
});
|
|
}
|
|
|
|
screenshot(args, context) {
|
|
return this.run(context.sessionId, async ({ page }) => {
|
|
const fullPage = args.fullPage === true;
|
|
const data = await page.screenshot({ type: "png", fullPage, animations: "disabled", caret: "hide" });
|
|
if (data.length > 8 * 1024 * 1024) throw new Error("Screenshot exceeds the 8 MiB response limit");
|
|
return {
|
|
content: [
|
|
{ type: "image", data: data.toString("base64"), mimeType: "image/png" },
|
|
{ type: "text", text: JSON.stringify({ url: page.url(), fullPage, bytes: data.length }) }
|
|
],
|
|
structuredContent: { url: page.url(), fullPage, bytes: data.length },
|
|
isError: false
|
|
};
|
|
});
|
|
}
|
|
|
|
consoleMessages(args, context) {
|
|
return this.run(context.sessionId, async (state) => {
|
|
const messages = [...state.console];
|
|
if (args.clear === true) state.console.length = 0;
|
|
return { messages };
|
|
});
|
|
}
|
|
|
|
networkErrors(args, context) {
|
|
return this.run(context.sessionId, async (state) => {
|
|
const errors = [...state.network];
|
|
if (args.clear === true) state.network.length = 0;
|
|
return { errors };
|
|
});
|
|
}
|
|
|
|
async closeSession(sessionId) {
|
|
const state = this.sessions.get(sessionId);
|
|
if (!state) return;
|
|
this.sessions.delete(sessionId);
|
|
await state.context.close().catch(() => {});
|
|
}
|
|
|
|
async close() {
|
|
await Promise.all([...this.sessions.keys()].map((id) => this.closeSession(id)));
|
|
if (this.browserPromise) await (await this.browserPromise).close().catch(() => {});
|
|
}
|
|
}
|