refactor: route browser control via gateway/node

This commit is contained in:
Peter Steinberger
2026-01-27 03:23:42 +00:00
parent b151b8d196
commit e7fdccce39
91 changed files with 1909 additions and 1608 deletions

View File

@@ -1,5 +1,3 @@
import type express from "express";
import type { BrowserFormField } from "../client-actions-core.js";
import type { BrowserRouteContext } from "../server-context.js";
import {
@@ -16,8 +14,12 @@ import {
SELECTOR_UNSUPPORTED_MESSAGE,
} from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringArray, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentActRoutes(app: express.Express, ctx: BrowserRouteContext) {
export function registerBrowserAgentActRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/act", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;

View File

@@ -2,13 +2,15 @@ import crypto from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
import { toBoolean, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentDebugRoutes(app: express.Express, ctx: BrowserRouteContext) {
export function registerBrowserAgentDebugRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.get("/console", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;

View File

@@ -1,9 +1,8 @@
import type express from "express";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import type { PwAiModule } from "../pw-ai-module.js";
import { getPwAiModule as getPwAiModuleBase } from "../pw-ai-module.js";
import { getProfileContext, jsonError } from "./utils.js";
import type { BrowserRequest, BrowserResponse } from "./types.js";
export const SELECTOR_UNSUPPORTED_MESSAGE = [
"Error: 'selector' is not supported. Use 'ref' from snapshot instead.",
@@ -15,21 +14,21 @@ export const SELECTOR_UNSUPPORTED_MESSAGE = [
"This is more reliable for modern SPAs.",
].join("\n");
export function readBody(req: express.Request): Record<string, unknown> {
export function readBody(req: BrowserRequest): Record<string, unknown> {
const body = req.body as Record<string, unknown> | undefined;
if (!body || typeof body !== "object" || Array.isArray(body)) return {};
return body;
}
export function handleRouteError(ctx: BrowserRouteContext, res: express.Response, err: unknown) {
export function handleRouteError(ctx: BrowserRouteContext, res: BrowserResponse, err: unknown) {
const mapped = ctx.mapTabError(err);
if (mapped) return jsonError(res, mapped.status, mapped.message);
jsonError(res, 500, String(err));
}
export function resolveProfileContext(
req: express.Request,
res: express.Response,
req: BrowserRequest,
res: BrowserResponse,
ctx: BrowserRouteContext,
): ProfileContext | null {
const profileCtx = getProfileContext(req, ctx);
@@ -45,7 +44,7 @@ export async function getPwAiModule(): Promise<PwAiModule | null> {
}
export async function requirePwAi(
res: express.Response,
res: BrowserResponse,
feature: string,
): Promise<PwAiModule | null> {
const mod = await getPwAiModule();

View File

@@ -1,7 +1,5 @@
import path from "node:path";
import type express from "express";
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
import { captureScreenshot, snapshotAria } from "../cdp.js";
import {
@@ -23,8 +21,12 @@ import {
resolveProfileContext,
} from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentSnapshotRoutes(app: express.Express, ctx: BrowserRouteContext) {
export function registerBrowserAgentSnapshotRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.post("/navigate", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;

View File

@@ -1,10 +1,12 @@
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import { handleRouteError, readBody, requirePwAi, resolveProfileContext } from "./agent.shared.js";
import { jsonError, toBoolean, toNumber, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentStorageRoutes(app: express.Express, ctx: BrowserRouteContext) {
export function registerBrowserAgentStorageRoutes(
app: BrowserRouteRegistrar,
ctx: BrowserRouteContext,
) {
app.get("/cookies", async (req, res) => {
const profileCtx = resolveProfileContext(req, res, ctx);
if (!profileCtx) return;

View File

@@ -1,12 +1,11 @@
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserAgentActRoutes } from "./agent.act.js";
import { registerBrowserAgentDebugRoutes } from "./agent.debug.js";
import { registerBrowserAgentSnapshotRoutes } from "./agent.snapshot.js";
import { registerBrowserAgentStorageRoutes } from "./agent.storage.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserAgentRoutes(app: express.Express, ctx: BrowserRouteContext) {
export function registerBrowserAgentRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
registerBrowserAgentSnapshotRoutes(app, ctx);
registerBrowserAgentActRoutes(app, ctx);
registerBrowserAgentDebugRoutes(app, ctx);

View File

@@ -1,11 +1,10 @@
import type express from "express";
import { resolveBrowserExecutableForPlatform } from "../chrome.executables.js";
import { createBrowserProfilesService } from "../profiles-service.js";
import type { BrowserRouteContext } from "../server-context.js";
import { getProfileContext, jsonError, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRouteContext) {
export function registerBrowserBasicRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
// List all profiles with their status
app.get("/profiles", async (_req, res) => {
try {
@@ -53,7 +52,6 @@ export function registerBrowserBasicRoutes(app: express.Express, ctx: BrowserRou
res.json({
enabled: current.resolved.enabled,
controlUrl: current.resolved.controlUrl,
profile: profileCtx.profile.name,
running: cdpReady,
cdpReady,

View File

@@ -0,0 +1,122 @@
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserRoutes } from "./index.js";
import type { BrowserRequest, BrowserResponse, BrowserRouteRegistrar } from "./types.js";
type BrowserDispatchRequest = {
method: "GET" | "POST" | "DELETE";
path: string;
query?: Record<string, unknown>;
body?: unknown;
};
type BrowserDispatchResponse = {
status: number;
body: unknown;
};
type RouteEntry = {
method: BrowserDispatchRequest["method"];
path: string;
regex: RegExp;
paramNames: string[];
handler: (req: BrowserRequest, res: BrowserResponse) => void | Promise<void>;
};
function escapeRegex(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function compileRoute(path: string): { regex: RegExp; paramNames: string[] } {
const paramNames: string[] = [];
const parts = path.split("/").map((part) => {
if (part.startsWith(":")) {
const name = part.slice(1);
paramNames.push(name);
return "([^/]+)";
}
return escapeRegex(part);
});
return { regex: new RegExp(`^${parts.join("/")}$`), paramNames };
}
function createRegistry() {
const routes: RouteEntry[] = [];
const register =
(method: RouteEntry["method"]) => (path: string, handler: RouteEntry["handler"]) => {
const { regex, paramNames } = compileRoute(path);
routes.push({ method, path, regex, paramNames, handler });
};
const router: BrowserRouteRegistrar = {
get: register("GET"),
post: register("POST"),
delete: register("DELETE"),
};
return { routes, router };
}
function normalizePath(path: string) {
if (!path) return "/";
return path.startsWith("/") ? path : `/${path}`;
}
export function createBrowserRouteDispatcher(ctx: BrowserRouteContext) {
const registry = createRegistry();
registerBrowserRoutes(registry.router, ctx);
return {
dispatch: async (req: BrowserDispatchRequest): Promise<BrowserDispatchResponse> => {
const method = req.method;
const path = normalizePath(req.path);
const query = req.query ?? {};
const body = req.body;
const match = registry.routes.find((route) => {
if (route.method !== method) return false;
return route.regex.test(path);
});
if (!match) {
return { status: 404, body: { error: "Not Found" } };
}
const exec = match.regex.exec(path);
const params: Record<string, string> = {};
if (exec) {
for (const [idx, name] of match.paramNames.entries()) {
const value = exec[idx + 1];
if (typeof value === "string") {
params[name] = decodeURIComponent(value);
}
}
}
let status = 200;
let payload: unknown = undefined;
const res: BrowserResponse = {
status(code) {
status = code;
return res;
},
json(bodyValue) {
payload = bodyValue;
},
};
try {
await match.handler(
{
params,
query,
body,
},
res,
);
} catch (err) {
return { status: 500, body: { error: String(err) } };
}
return { status, body: payload };
},
};
}
export type { BrowserDispatchRequest, BrowserDispatchResponse };

View File

@@ -1,11 +1,10 @@
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import { registerBrowserAgentRoutes } from "./agent.js";
import { registerBrowserBasicRoutes } from "./basic.js";
import { registerBrowserTabRoutes } from "./tabs.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserRoutes(app: express.Express, ctx: BrowserRouteContext) {
export function registerBrowserRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
registerBrowserBasicRoutes(app, ctx);
registerBrowserTabRoutes(app, ctx);
registerBrowserAgentRoutes(app, ctx);

View File

@@ -1,9 +1,8 @@
import type express from "express";
import type { BrowserRouteContext } from "../server-context.js";
import { getProfileContext, jsonError, toNumber, toStringOrEmpty } from "./utils.js";
import type { BrowserRouteRegistrar } from "./types.js";
export function registerBrowserTabRoutes(app: express.Express, ctx: BrowserRouteContext) {
export function registerBrowserTabRoutes(app: BrowserRouteRegistrar, ctx: BrowserRouteContext) {
app.get("/tabs", async (req, res) => {
const profileCtx = getProfileContext(req, ctx);
if ("error" in profileCtx) return jsonError(res, profileCtx.status, profileCtx.error);

View File

@@ -0,0 +1,21 @@
export type BrowserRequest = {
params: Record<string, string>;
query: Record<string, unknown>;
body?: unknown;
};
export type BrowserResponse = {
status: (code: number) => BrowserResponse;
json: (body: unknown) => void;
};
export type BrowserRouteHandler = (
req: BrowserRequest,
res: BrowserResponse,
) => void | Promise<void>;
export type BrowserRouteRegistrar = {
get: (path: string, handler: BrowserRouteHandler) => void;
post: (path: string, handler: BrowserRouteHandler) => void;
delete: (path: string, handler: BrowserRouteHandler) => void;
};

View File

@@ -1,14 +1,13 @@
import type express from "express";
import type { BrowserRouteContext, ProfileContext } from "../server-context.js";
import { parseBooleanValue } from "../../utils/boolean.js";
import type { BrowserRequest, BrowserResponse } from "./types.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,
req: BrowserRequest,
ctx: BrowserRouteContext,
): ProfileContext | { error: string; status: number } {
let profileName: string | undefined;
@@ -33,7 +32,7 @@ export function getProfileContext(
}
}
export function jsonError(res: express.Response, status: number, message: string) {
export function jsonError(res: BrowserResponse, status: number, message: string) {
res.status(status).json({ error: message });
}