feat(browser): add remote-capable profiles
Co-authored-by: James Groat <james@groat.com>
This commit is contained in:
@@ -10,8 +10,9 @@ import {
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
normalizeBrowserScreenshot,
|
||||
} from "../screenshot.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
import {
|
||||
getProfileContext,
|
||||
jsonError,
|
||||
toBoolean,
|
||||
toNumber,
|
||||
@@ -61,6 +62,19 @@ function handleRouteError(
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
|
||||
function resolveProfileContext(
|
||||
req: express.Request,
|
||||
res: express.Response,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | null {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
jsonError(res, profileCtx.status, profileCtx.error);
|
||||
return null;
|
||||
}
|
||||
return profileCtx;
|
||||
}
|
||||
|
||||
function parseClickButton(raw: string): ClickButton | undefined {
|
||||
if (raw === "left" || raw === "right" || raw === "middle") return raw;
|
||||
return undefined;
|
||||
@@ -100,16 +114,18 @@ export function registerBrowserAgentRoutes(
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/navigate", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
const body = readBody(req);
|
||||
const url = toStringOrEmpty(body.url);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
if (!url) return jsonError(res, 400, "url is required");
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||
const pw = await requirePwAi(res, "navigate");
|
||||
if (!pw) return;
|
||||
const result = await pw.navigateViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
url,
|
||||
});
|
||||
@@ -120,6 +136,8 @@ export function registerBrowserAgentRoutes(
|
||||
});
|
||||
|
||||
app.post("/act", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
const body = readBody(req);
|
||||
const kind = toStringOrEmpty(body.kind) as ActKind;
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
@@ -144,8 +162,8 @@ export function registerBrowserAgentRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const cdpUrl = ctx.state().resolved.cdpUrl;
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||
const cdpUrl = profileCtx.profile.cdpUrl;
|
||||
const pw = await requirePwAi(res, `act:${kind}`);
|
||||
if (!pw) return;
|
||||
|
||||
@@ -336,6 +354,8 @@ export function registerBrowserAgentRoutes(
|
||||
});
|
||||
|
||||
app.post("/hooks/file-chooser", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const ref = toStringOrEmpty(body.ref) || undefined;
|
||||
@@ -345,7 +365,7 @@ export function registerBrowserAgentRoutes(
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
if (!paths.length) return jsonError(res, 400, "paths are required");
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||
const pw = await requirePwAi(res, "file chooser hook");
|
||||
if (!pw) return;
|
||||
if (inputRef || element) {
|
||||
@@ -357,7 +377,7 @@ export function registerBrowserAgentRoutes(
|
||||
);
|
||||
}
|
||||
await pw.setInputFilesViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
inputRef,
|
||||
element,
|
||||
@@ -365,14 +385,14 @@ export function registerBrowserAgentRoutes(
|
||||
});
|
||||
} else {
|
||||
await pw.armFileUploadViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
paths,
|
||||
timeoutMs: timeoutMs ?? undefined,
|
||||
});
|
||||
if (ref) {
|
||||
await pw.clickViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
});
|
||||
@@ -385,6 +405,8 @@ export function registerBrowserAgentRoutes(
|
||||
});
|
||||
|
||||
app.post("/hooks/dialog", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const accept = toBoolean(body.accept);
|
||||
@@ -392,11 +414,11 @@ export function registerBrowserAgentRoutes(
|
||||
const timeoutMs = toNumber(body.timeoutMs);
|
||||
if (accept === undefined) return jsonError(res, 400, "accept is required");
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||
const pw = await requirePwAi(res, "dialog hook");
|
||||
if (!pw) return;
|
||||
await pw.armDialogViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
accept,
|
||||
promptText,
|
||||
@@ -409,16 +431,18 @@ export function registerBrowserAgentRoutes(
|
||||
});
|
||||
|
||||
app.get("/console", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const level = typeof req.query.level === "string" ? req.query.level : "";
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||
const pw = await requirePwAi(res, "console messages");
|
||||
if (!pw) return;
|
||||
const messages = await pw.getConsoleMessagesViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
level: level.trim() || undefined,
|
||||
});
|
||||
@@ -429,14 +453,16 @@ export function registerBrowserAgentRoutes(
|
||||
});
|
||||
|
||||
app.post("/pdf", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||
const pw = await requirePwAi(res, "pdf");
|
||||
if (!pw) return;
|
||||
const pdf = await pw.pdfViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
@@ -458,6 +484,8 @@ export function registerBrowserAgentRoutes(
|
||||
});
|
||||
|
||||
app.post("/screenshot", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
const body = readBody(req);
|
||||
const targetId = toStringOrEmpty(body.targetId) || undefined;
|
||||
const fullPage = toBoolean(body.fullPage) ?? false;
|
||||
@@ -474,13 +502,13 @@ export function registerBrowserAgentRoutes(
|
||||
}
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId);
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId);
|
||||
let buffer: Buffer;
|
||||
if (ref || element) {
|
||||
const pw = await requirePwAi(res, "element/ref screenshot");
|
||||
if (!pw) return;
|
||||
const snap = await pw.takeScreenshotViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
element,
|
||||
@@ -520,6 +548,8 @@ export function registerBrowserAgentRoutes(
|
||||
});
|
||||
|
||||
app.get("/snapshot", async (req, res) => {
|
||||
const profileCtx = resolveProfileContext(req, res, ctx);
|
||||
if (!profileCtx) return;
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const format =
|
||||
@@ -534,12 +564,12 @@ export function registerBrowserAgentRoutes(
|
||||
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const tab = await profileCtx.ensureTabAvailable(targetId || undefined);
|
||||
if (format === "ai") {
|
||||
const pw = await requirePwAi(res, "ai snapshot");
|
||||
if (!pw) return;
|
||||
const snap = await pw.snapshotAiViaPlaywright({
|
||||
cdpUrl: ctx.state().resolved.cdpUrl,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
return res.json({
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import type express from "express";
|
||||
|
||||
import { createBrowserProfilesService } from "../profiles-service.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { jsonError } from "./utils.js";
|
||||
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export function registerBrowserBasicRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/", async (_req, res) => {
|
||||
// List all profiles with their status
|
||||
app.get("/profiles", async (_req, res) => {
|
||||
try {
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const profiles = await service.listProfiles();
|
||||
res.json({ profiles });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Get status (profile-aware)
|
||||
app.get("/", async (req, res) => {
|
||||
let current: ReturnType<typeof ctx.state>;
|
||||
try {
|
||||
current = ctx.state();
|
||||
@@ -15,22 +28,31 @@ export function registerBrowserBasicRoutes(
|
||||
return jsonError(res, 503, "browser server not started");
|
||||
}
|
||||
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
const [cdpHttp, cdpReady] = await Promise.all([
|
||||
ctx.isHttpReachable(300),
|
||||
ctx.isReachable(600),
|
||||
profileCtx.isHttpReachable(300),
|
||||
profileCtx.isReachable(600),
|
||||
]);
|
||||
|
||||
const profileState = current.profiles.get(profileCtx.profile.name);
|
||||
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
controlUrl: current.resolved.controlUrl,
|
||||
profile: profileCtx.profile.name,
|
||||
running: cdpReady,
|
||||
cdpReady,
|
||||
cdpHttp,
|
||||
pid: current.running?.pid ?? null,
|
||||
cdpPort: current.cdpPort,
|
||||
cdpUrl: current.resolved.cdpUrl,
|
||||
chosenBrowser: current.running?.exe.kind ?? null,
|
||||
userDataDir: current.running?.userDataDir ?? null,
|
||||
color: current.resolved.color,
|
||||
pid: profileState?.running?.pid ?? null,
|
||||
cdpPort: profileCtx.profile.cdpPort,
|
||||
cdpUrl: profileCtx.profile.cdpUrl,
|
||||
chosenBrowser: profileState?.running?.exe.kind ?? null,
|
||||
userDataDir: profileState?.running?.userDataDir ?? null,
|
||||
color: profileCtx.profile.color,
|
||||
headless: current.resolved.headless,
|
||||
noSandbox: current.resolved.noSandbox,
|
||||
executablePath: current.resolved.executablePath ?? null,
|
||||
@@ -38,30 +60,110 @@ export function registerBrowserBasicRoutes(
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/start", async (_req, res) => {
|
||||
// Start browser (profile-aware)
|
||||
app.post("/start", async (req, res) => {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
try {
|
||||
await ctx.ensureBrowserAvailable();
|
||||
res.json({ ok: true });
|
||||
await profileCtx.ensureBrowserAvailable();
|
||||
res.json({ ok: true, profile: profileCtx.profile.name });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/stop", async (_req, res) => {
|
||||
// Stop browser (profile-aware)
|
||||
app.post("/stop", async (req, res) => {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.stopRunningBrowser();
|
||||
res.json({ ok: true, stopped: result.stopped });
|
||||
const result = await profileCtx.stopRunningBrowser();
|
||||
res.json({
|
||||
ok: true,
|
||||
stopped: result.stopped,
|
||||
profile: profileCtx.profile.name,
|
||||
});
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/reset-profile", async (_req, res) => {
|
||||
// Reset profile (profile-aware)
|
||||
app.post("/reset-profile", async (req, res) => {
|
||||
const profileCtx = getProfileContext(req, ctx);
|
||||
if ("error" in profileCtx) {
|
||||
return jsonError(res, profileCtx.status, profileCtx.error);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await ctx.resetProfile();
|
||||
res.json({ ok: true, ...result });
|
||||
const result = await profileCtx.resetProfile();
|
||||
res.json({ ok: true, profile: profileCtx.profile.name, ...result });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
// Create a new profile
|
||||
app.post("/profiles/create", async (req, res) => {
|
||||
const name = toStringOrEmpty((req.body as { name?: unknown })?.name);
|
||||
const color = toStringOrEmpty((req.body as { color?: unknown })?.color);
|
||||
const cdpUrl = toStringOrEmpty((req.body as { cdpUrl?: unknown })?.cdpUrl);
|
||||
|
||||
if (!name) return jsonError(res, 400, "name is required");
|
||||
|
||||
try {
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.createProfile({
|
||||
name,
|
||||
color: color || undefined,
|
||||
cdpUrl: cdpUrl || undefined,
|
||||
});
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("already exists")) {
|
||||
return jsonError(res, 409, msg);
|
||||
}
|
||||
if (msg.includes("invalid profile name")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
if (msg.includes("no available CDP ports")) {
|
||||
return jsonError(res, 507, msg);
|
||||
}
|
||||
if (msg.includes("cdpUrl")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
jsonError(res, 500, msg);
|
||||
}
|
||||
});
|
||||
|
||||
// Delete a profile
|
||||
app.delete("/profiles/:name", async (req, res) => {
|
||||
const name = toStringOrEmpty(req.params.name);
|
||||
if (!name) return jsonError(res, 400, "profile name is required");
|
||||
|
||||
try {
|
||||
const service = createBrowserProfilesService(ctx);
|
||||
const result = await service.deleteProfile(name);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
const msg = String(err);
|
||||
if (msg.includes("invalid profile name")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
if (msg.includes("default profile")) {
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
if (msg.includes("not found")) {
|
||||
return jsonError(res, 404, msg);
|
||||
}
|
||||
jsonError(res, 500, msg);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
|
||||
|
||||
/**
|
||||
* Extract profile name from query string or body and get profile context.
|
||||
* Query string takes precedence over body for consistency with GET routes.
|
||||
*/
|
||||
export function getProfileContext(
|
||||
req: express.Request,
|
||||
ctx: BrowserRouteContext,
|
||||
): ProfileContext | { error: string; status: number } {
|
||||
let profileName: string | undefined;
|
||||
|
||||
// Check query string first (works for GET and POST)
|
||||
if (typeof req.query.profile === "string") {
|
||||
profileName = req.query.profile.trim() || undefined;
|
||||
}
|
||||
|
||||
// Fall back to body for POST requests
|
||||
if (!profileName && req.body && typeof req.body === "object") {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
if (typeof body.profile === "string") {
|
||||
profileName = body.profile.trim() || undefined;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return ctx.forProfile(profileName);
|
||||
} catch (err) {
|
||||
return { error: String(err), status: 404 };
|
||||
}
|
||||
}
|
||||
|
||||
export function jsonError(
|
||||
res: express.Response,
|
||||
status: number,
|
||||
|
||||
Reference in New Issue
Block a user