feat(browser): add MCP tool dispatch
This commit is contained in:
50
src/browser/routes/basic.ts
Normal file
50
src/browser/routes/basic.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { jsonError } from "./utils.js";
|
||||
|
||||
export function registerBrowserBasicRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/", async (_req, res) => {
|
||||
let current: ReturnType<typeof ctx.state>;
|
||||
try {
|
||||
current = ctx.state();
|
||||
} catch {
|
||||
return jsonError(res, 503, "browser server not started");
|
||||
}
|
||||
|
||||
const reachable = await ctx.isReachable(300);
|
||||
res.json({
|
||||
enabled: current.resolved.enabled,
|
||||
controlUrl: current.resolved.controlUrl,
|
||||
running: reachable,
|
||||
pid: current.running?.pid ?? null,
|
||||
cdpPort: current.cdpPort,
|
||||
chosenBrowser: current.running?.exe.kind ?? null,
|
||||
userDataDir: current.running?.userDataDir ?? null,
|
||||
color: current.resolved.color,
|
||||
headless: current.resolved.headless,
|
||||
attachOnly: current.resolved.attachOnly,
|
||||
});
|
||||
});
|
||||
|
||||
app.post("/start", async (_req, res) => {
|
||||
try {
|
||||
await ctx.ensureBrowserAvailable();
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/stop", async (_req, res) => {
|
||||
try {
|
||||
const result = await ctx.stopRunningBrowser();
|
||||
res.json({ ok: true, stopped: result.stopped });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
17
src/browser/routes/index.ts
Normal file
17
src/browser/routes/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { registerBrowserBasicRoutes } from "./basic.js";
|
||||
import { registerBrowserInspectRoutes } from "./inspect.js";
|
||||
import { registerBrowserTabRoutes } from "./tabs.js";
|
||||
import { registerBrowserToolRoutes } from "./tool.js";
|
||||
|
||||
export function registerBrowserRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
registerBrowserBasicRoutes(app, ctx);
|
||||
registerBrowserTabRoutes(app, ctx);
|
||||
registerBrowserInspectRoutes(app, ctx);
|
||||
registerBrowserToolRoutes(app, ctx);
|
||||
}
|
||||
307
src/browser/routes/inspect.ts
Normal file
307
src/browser/routes/inspect.ts
Normal file
@@ -0,0 +1,307 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import {
|
||||
captureScreenshot,
|
||||
captureScreenshotPng,
|
||||
evaluateJavaScript,
|
||||
getDomText,
|
||||
querySelector,
|
||||
snapshotAria,
|
||||
snapshotDom,
|
||||
} from "../cdp.js";
|
||||
import {
|
||||
snapshotAiViaPlaywright,
|
||||
takeScreenshotViaPlaywright,
|
||||
} from "../pw-ai.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
normalizeBrowserScreenshot,
|
||||
} from "../screenshot.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { jsonError, toBoolean, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export function registerBrowserInspectRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/screenshot", async (req, res) => {
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const fullPage =
|
||||
req.query.fullPage === "true" || req.query.fullPage === "1";
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
|
||||
let shot: Buffer<ArrayBufferLike> = Buffer.alloc(0);
|
||||
let contentTypeHint: "image/jpeg" | "image/png" = "image/jpeg";
|
||||
try {
|
||||
shot = await captureScreenshot({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
fullPage,
|
||||
format: "jpeg",
|
||||
quality: 85,
|
||||
});
|
||||
} catch {
|
||||
contentTypeHint = "image/png";
|
||||
shot = await captureScreenshotPng({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
fullPage,
|
||||
});
|
||||
}
|
||||
|
||||
const normalized = await normalizeBrowserScreenshot(shot, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? contentTypeHint,
|
||||
"browser",
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
);
|
||||
const filePath = path.resolve(saved.path);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: filePath,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/screenshot", async (req, res) => {
|
||||
const body = req.body as Record<string, unknown>;
|
||||
const targetId = toStringOrEmpty(body?.targetId);
|
||||
const fullPage = toBoolean(body?.fullPage) ?? false;
|
||||
const ref = toStringOrEmpty(body?.ref);
|
||||
const element = toStringOrEmpty(body?.element);
|
||||
const type = body?.type === "jpeg" ? "jpeg" : "png";
|
||||
const filename = toStringOrEmpty(body?.filename);
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const snap = await takeScreenshotViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
element,
|
||||
fullPage,
|
||||
type,
|
||||
});
|
||||
const buffer = snap.buffer;
|
||||
const normalized = await normalizeBrowserScreenshot(buffer, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? `image/${type}`,
|
||||
"browser",
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
);
|
||||
const filePath = path.resolve(saved.path);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: filePath,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
filename: filename || undefined,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/eval", async (req, res) => {
|
||||
const js = toStringOrEmpty((req.body as { js?: unknown })?.js);
|
||||
const targetId = toStringOrEmpty(
|
||||
(req.body as { targetId?: unknown })?.targetId,
|
||||
);
|
||||
const awaitPromise = Boolean((req.body as { await?: unknown })?.await);
|
||||
|
||||
if (!js) return jsonError(res, 400, "js is required");
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const evaluated = await evaluateJavaScript({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
expression: js,
|
||||
awaitPromise,
|
||||
returnByValue: true,
|
||||
});
|
||||
|
||||
if (evaluated.exceptionDetails) {
|
||||
const msg =
|
||||
evaluated.exceptionDetails.exception?.description ||
|
||||
evaluated.exceptionDetails.text ||
|
||||
"JavaScript evaluation failed";
|
||||
return jsonError(res, 400, msg);
|
||||
}
|
||||
|
||||
res.json({
|
||||
ok: true,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
result: evaluated.result,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/query", async (req, res) => {
|
||||
const selector =
|
||||
typeof req.query.selector === "string" ? req.query.selector.trim() : "";
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const limit =
|
||||
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
||||
|
||||
if (!selector) return jsonError(res, 400, "selector is required");
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const result = await querySelector({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
selector,
|
||||
limit,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url, ...result });
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/dom", async (req, res) => {
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const format = req.query.format === "text" ? "text" : "html";
|
||||
const selector =
|
||||
typeof req.query.selector === "string" ? req.query.selector.trim() : "";
|
||||
const maxChars =
|
||||
typeof req.query.maxChars === "string"
|
||||
? Number(req.query.maxChars)
|
||||
: undefined;
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
const result = await getDomText({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
format,
|
||||
maxChars,
|
||||
selector: selector || undefined,
|
||||
});
|
||||
res.json({
|
||||
ok: true,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
format,
|
||||
...result,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/snapshot", async (req, res) => {
|
||||
const targetId =
|
||||
typeof req.query.targetId === "string" ? req.query.targetId.trim() : "";
|
||||
const format =
|
||||
req.query.format === "domSnapshot"
|
||||
? "domSnapshot"
|
||||
: req.query.format === "ai"
|
||||
? "ai"
|
||||
: "aria";
|
||||
const limit =
|
||||
typeof req.query.limit === "string" ? Number(req.query.limit) : undefined;
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
|
||||
if (format === "ai") {
|
||||
const snap = await snapshotAiViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
if (format === "aria") {
|
||||
const snap = await snapshotAria({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
limit,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
}
|
||||
|
||||
const snap = await snapshotDom({
|
||||
wsUrl: tab.wsUrl ?? "",
|
||||
limit,
|
||||
});
|
||||
return res.json({
|
||||
ok: true,
|
||||
format,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
...snap,
|
||||
});
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/click", async (req, res) => {
|
||||
const ref = toStringOrEmpty((req.body as { ref?: unknown })?.ref);
|
||||
const targetId = toStringOrEmpty(
|
||||
(req.body as { targetId?: unknown })?.targetId,
|
||||
);
|
||||
|
||||
if (!ref) return jsonError(res, 400, "ref is required");
|
||||
|
||||
try {
|
||||
const tab = await ctx.ensureTabAvailable(targetId || undefined);
|
||||
await clickViaPlaywright({
|
||||
cdpPort: ctx.state().cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
108
src/browser/routes/tabs.ts
Normal file
108
src/browser/routes/tabs.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { jsonError, toNumber, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
export function registerBrowserTabRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.get("/tabs", async (_req, res) => {
|
||||
try {
|
||||
const reachable = await ctx.isReachable(300);
|
||||
if (!reachable)
|
||||
return res.json({ running: false, tabs: [] as unknown[] });
|
||||
const tabs = await ctx.listTabs();
|
||||
res.json({ running: true, tabs });
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/tabs/open", async (req, res) => {
|
||||
const url = toStringOrEmpty((req.body as { url?: unknown })?.url);
|
||||
if (!url) return jsonError(res, 400, "url is required");
|
||||
try {
|
||||
await ctx.ensureBrowserAvailable();
|
||||
const tab = await ctx.openTab(url);
|
||||
res.json(tab);
|
||||
} catch (err) {
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/tabs/focus", async (req, res) => {
|
||||
const targetId = toStringOrEmpty(
|
||||
(req.body as { targetId?: unknown })?.targetId,
|
||||
);
|
||||
if (!targetId) return jsonError(res, 400, "targetId is required");
|
||||
try {
|
||||
if (!(await ctx.isReachable(300)))
|
||||
return jsonError(res, 409, "browser not running");
|
||||
await ctx.focusTab(targetId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/tabs/:targetId", async (req, res) => {
|
||||
const targetId = toStringOrEmpty(req.params.targetId);
|
||||
if (!targetId) return jsonError(res, 400, "targetId is required");
|
||||
try {
|
||||
if (!(await ctx.isReachable(300)))
|
||||
return jsonError(res, 409, "browser not running");
|
||||
await ctx.closeTab(targetId);
|
||||
res.json({ ok: true });
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/tabs/action", async (req, res) => {
|
||||
const action = toStringOrEmpty((req.body as { action?: unknown })?.action);
|
||||
const index = toNumber((req.body as { index?: unknown })?.index);
|
||||
try {
|
||||
if (action === "list") {
|
||||
const reachable = await ctx.isReachable(300);
|
||||
if (!reachable) return res.json({ ok: true, tabs: [] as unknown[] });
|
||||
const tabs = await ctx.listTabs();
|
||||
return res.json({ ok: true, tabs });
|
||||
}
|
||||
|
||||
if (action === "new") {
|
||||
await ctx.ensureBrowserAvailable();
|
||||
const tab = await ctx.openTab("about:blank");
|
||||
return res.json({ ok: true, tab });
|
||||
}
|
||||
|
||||
if (action === "close") {
|
||||
const tabs = await ctx.listTabs();
|
||||
const target = typeof index === "number" ? tabs[index] : tabs.at(0);
|
||||
if (!target) return jsonError(res, 404, "tab not found");
|
||||
await ctx.closeTab(target.targetId);
|
||||
return res.json({ ok: true, targetId: target.targetId });
|
||||
}
|
||||
|
||||
if (action === "select") {
|
||||
if (typeof index !== "number")
|
||||
return jsonError(res, 400, "index is required");
|
||||
const tabs = await ctx.listTabs();
|
||||
const target = tabs[index];
|
||||
if (!target) return jsonError(res, 404, "tab not found");
|
||||
await ctx.focusTab(target.targetId);
|
||||
return res.json({ ok: true, targetId: target.targetId });
|
||||
}
|
||||
|
||||
return jsonError(res, 400, "unknown tab action");
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
432
src/browser/routes/tool-core.ts
Normal file
432
src/browser/routes/tool-core.ts
Normal file
@@ -0,0 +1,432 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import {
|
||||
clickViaPlaywright,
|
||||
closePageViaPlaywright,
|
||||
dragViaPlaywright,
|
||||
evaluateViaPlaywright,
|
||||
fileUploadViaPlaywright,
|
||||
fillFormViaPlaywright,
|
||||
handleDialogViaPlaywright,
|
||||
hoverViaPlaywright,
|
||||
navigateBackViaPlaywright,
|
||||
navigateViaPlaywright,
|
||||
pressKeyViaPlaywright,
|
||||
resizeViewportViaPlaywright,
|
||||
runCodeViaPlaywright,
|
||||
selectOptionViaPlaywright,
|
||||
snapshotAiViaPlaywright,
|
||||
takeScreenshotViaPlaywright,
|
||||
typeViaPlaywright,
|
||||
waitForViaPlaywright,
|
||||
} from "../pw-ai.js";
|
||||
import {
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
normalizeBrowserScreenshot,
|
||||
} from "../screenshot.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
jsonError,
|
||||
toBoolean,
|
||||
toNumber,
|
||||
toStringArray,
|
||||
toStringOrEmpty,
|
||||
} from "./utils.js";
|
||||
|
||||
type ToolCoreParams = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
targetId: string;
|
||||
cdpPort: number;
|
||||
ctx: BrowserRouteContext;
|
||||
res: express.Response;
|
||||
};
|
||||
|
||||
export async function handleBrowserToolCore(
|
||||
params: ToolCoreParams,
|
||||
): Promise<boolean> {
|
||||
const { name, args, targetId, cdpPort, ctx, res } = params;
|
||||
const target = targetId || undefined;
|
||||
|
||||
switch (name) {
|
||||
case "browser_close": {
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await closePageViaPlaywright({ cdpPort, targetId: tab.targetId });
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
return true;
|
||||
}
|
||||
case "browser_resize": {
|
||||
const width = toNumber(args.width);
|
||||
const height = toNumber(args.height);
|
||||
if (!width || !height) {
|
||||
jsonError(res, 400, "width and height are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await resizeViewportViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
width,
|
||||
height,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
return true;
|
||||
}
|
||||
case "browser_handle_dialog": {
|
||||
const accept = toBoolean(args.accept);
|
||||
if (accept === undefined) {
|
||||
jsonError(res, 400, "accept is required");
|
||||
return true;
|
||||
}
|
||||
const promptText = toStringOrEmpty(args.promptText) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const result = await handleDialogViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
accept,
|
||||
promptText,
|
||||
});
|
||||
res.json({ ok: true, ...result });
|
||||
return true;
|
||||
}
|
||||
case "browser_evaluate": {
|
||||
const fn = toStringOrEmpty(args.function);
|
||||
if (!fn) {
|
||||
jsonError(res, 400, "function is required");
|
||||
return true;
|
||||
}
|
||||
const ref = toStringOrEmpty(args.ref) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const result = await evaluateViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
fn,
|
||||
ref,
|
||||
});
|
||||
res.json({ ok: true, result });
|
||||
return true;
|
||||
}
|
||||
case "browser_file_upload": {
|
||||
const paths = toStringArray(args.paths) ?? [];
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await fileUploadViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
paths: paths.length ? paths : undefined,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_fill_form": {
|
||||
const fields = Array.isArray(args.fields)
|
||||
? (args.fields as Array<Record<string, unknown>>)
|
||||
: null;
|
||||
if (!fields?.length) {
|
||||
jsonError(res, 400, "fields are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await fillFormViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
fields,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_install": {
|
||||
res.json({
|
||||
ok: true,
|
||||
message:
|
||||
"clawd browser uses system Chrome/Chromium; no Playwright install needed.",
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "browser_press_key": {
|
||||
const key = toStringOrEmpty(args.key);
|
||||
if (!key) {
|
||||
jsonError(res, 400, "key is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await pressKeyViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
key,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_type": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
const text = toStringOrEmpty(args.text);
|
||||
if (!ref || !text) {
|
||||
jsonError(res, 400, "ref and text are required");
|
||||
return true;
|
||||
}
|
||||
const submit = toBoolean(args.submit) ?? false;
|
||||
const slowly = toBoolean(args.slowly) ?? false;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await typeViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
text,
|
||||
submit,
|
||||
slowly,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_navigate": {
|
||||
const url = toStringOrEmpty(args.url);
|
||||
if (!url) {
|
||||
jsonError(res, 400, "url is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const result = await navigateViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
url,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
return true;
|
||||
}
|
||||
case "browser_navigate_back": {
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const result = await navigateBackViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, ...result });
|
||||
return true;
|
||||
}
|
||||
case "browser_run_code": {
|
||||
const code = toStringOrEmpty(args.code);
|
||||
if (!code) {
|
||||
jsonError(res, 400, "code is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const result = await runCodeViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
code,
|
||||
});
|
||||
res.json({ ok: true, result });
|
||||
return true;
|
||||
}
|
||||
case "browser_take_screenshot": {
|
||||
const type = args.type === "jpeg" ? "jpeg" : "png";
|
||||
const ref = toStringOrEmpty(args.ref) || undefined;
|
||||
const fullPage = toBoolean(args.fullPage) ?? false;
|
||||
const element = toStringOrEmpty(args.element) || undefined;
|
||||
const filename = toStringOrEmpty(args.filename) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const snap = await takeScreenshotViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
element,
|
||||
fullPage,
|
||||
type,
|
||||
});
|
||||
const normalized = await normalizeBrowserScreenshot(snap.buffer, {
|
||||
maxSide: DEFAULT_BROWSER_SCREENSHOT_MAX_SIDE,
|
||||
maxBytes: DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
normalized.buffer,
|
||||
normalized.contentType ?? `image/${type}`,
|
||||
"browser",
|
||||
DEFAULT_BROWSER_SCREENSHOT_MAX_BYTES,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: path.resolve(saved.path),
|
||||
filename,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "browser_snapshot": {
|
||||
const filename = toStringOrEmpty(args.filename) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const snap = await snapshotAiViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
if (filename) {
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
Buffer.from(snap.snapshot, "utf8"),
|
||||
"text/plain",
|
||||
"browser",
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: path.resolve(saved.path),
|
||||
filename,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
res.json({
|
||||
ok: true,
|
||||
snapshot: snap.snapshot,
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "browser_click": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
if (!ref) {
|
||||
jsonError(res, 400, "ref is required");
|
||||
return true;
|
||||
}
|
||||
const doubleClick = toBoolean(args.doubleClick) ?? false;
|
||||
const button = toStringOrEmpty(args.button) || undefined;
|
||||
const modifiers = Array.isArray(args.modifiers)
|
||||
? (args.modifiers as string[])
|
||||
: undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await clickViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
doubleClick,
|
||||
button,
|
||||
modifiers,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId, url: tab.url });
|
||||
return true;
|
||||
}
|
||||
case "browser_drag": {
|
||||
const startRef = toStringOrEmpty(args.startRef);
|
||||
const endRef = toStringOrEmpty(args.endRef);
|
||||
if (!startRef || !endRef) {
|
||||
jsonError(res, 400, "startRef and endRef are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await dragViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
startRef,
|
||||
endRef,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_hover": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
if (!ref) {
|
||||
jsonError(res, 400, "ref is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await hoverViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_select_option": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
const values = toStringArray(args.values);
|
||||
if (!ref || !values?.length) {
|
||||
jsonError(res, 400, "ref and values are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await selectOptionViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
values,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_tabs": {
|
||||
const action = toStringOrEmpty(args.action);
|
||||
const index = toNumber(args.index);
|
||||
if (!action) {
|
||||
jsonError(res, 400, "action is required");
|
||||
return true;
|
||||
}
|
||||
if (action === "list") {
|
||||
const reachable = await ctx.isReachable(300);
|
||||
if (!reachable) {
|
||||
res.json({ ok: true, tabs: [] });
|
||||
return true;
|
||||
}
|
||||
const tabs = await ctx.listTabs();
|
||||
res.json({ ok: true, tabs });
|
||||
return true;
|
||||
}
|
||||
if (action === "new") {
|
||||
await ctx.ensureBrowserAvailable();
|
||||
const tab = await ctx.openTab("about:blank");
|
||||
res.json({ ok: true, tab });
|
||||
return true;
|
||||
}
|
||||
if (action === "close") {
|
||||
const tabs = await ctx.listTabs();
|
||||
const targetTab = typeof index === "number" ? tabs[index] : tabs.at(0);
|
||||
if (!targetTab) {
|
||||
jsonError(res, 404, "tab not found");
|
||||
return true;
|
||||
}
|
||||
await ctx.closeTab(targetTab.targetId);
|
||||
res.json({ ok: true, targetId: targetTab.targetId });
|
||||
return true;
|
||||
}
|
||||
if (action === "select") {
|
||||
if (typeof index !== "number") {
|
||||
jsonError(res, 400, "index is required");
|
||||
return true;
|
||||
}
|
||||
const tabs = await ctx.listTabs();
|
||||
const targetTab = tabs[index];
|
||||
if (!targetTab) {
|
||||
jsonError(res, 404, "tab not found");
|
||||
return true;
|
||||
}
|
||||
await ctx.focusTab(targetTab.targetId);
|
||||
res.json({ ok: true, targetId: targetTab.targetId });
|
||||
return true;
|
||||
}
|
||||
jsonError(res, 400, "unknown tab action");
|
||||
return true;
|
||||
}
|
||||
case "browser_wait_for": {
|
||||
const time = toNumber(args.time);
|
||||
const text = toStringOrEmpty(args.text) || undefined;
|
||||
const textGone = toStringOrEmpty(args.textGone) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await waitForViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
time,
|
||||
text,
|
||||
textGone,
|
||||
});
|
||||
res.json({ ok: true, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
262
src/browser/routes/tool-extra.ts
Normal file
262
src/browser/routes/tool-extra.ts
Normal file
@@ -0,0 +1,262 @@
|
||||
import path from "node:path";
|
||||
|
||||
import type express from "express";
|
||||
|
||||
import { ensureMediaDir, saveMediaBuffer } from "../../media/store.js";
|
||||
import {
|
||||
generateLocatorForRef,
|
||||
getConsoleMessagesViaPlaywright,
|
||||
getNetworkRequestsViaPlaywright,
|
||||
mouseClickViaPlaywright,
|
||||
mouseDragViaPlaywright,
|
||||
mouseMoveViaPlaywright,
|
||||
pdfViaPlaywright,
|
||||
startTracingViaPlaywright,
|
||||
stopTracingViaPlaywright,
|
||||
verifyElementVisibleViaPlaywright,
|
||||
verifyListVisibleViaPlaywright,
|
||||
verifyTextVisibleViaPlaywright,
|
||||
verifyValueViaPlaywright,
|
||||
} from "../pw-ai.js";
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import {
|
||||
jsonError,
|
||||
toBoolean,
|
||||
toNumber,
|
||||
toStringArray,
|
||||
toStringOrEmpty,
|
||||
} from "./utils.js";
|
||||
|
||||
type ToolExtraParams = {
|
||||
name: string;
|
||||
args: Record<string, unknown>;
|
||||
targetId: string;
|
||||
cdpPort: number;
|
||||
ctx: BrowserRouteContext;
|
||||
res: express.Response;
|
||||
};
|
||||
|
||||
export async function handleBrowserToolExtra(
|
||||
params: ToolExtraParams,
|
||||
): Promise<boolean> {
|
||||
const { name, args, targetId, cdpPort, ctx, res } = params;
|
||||
const target = targetId || undefined;
|
||||
|
||||
switch (name) {
|
||||
case "browser_console_messages": {
|
||||
const level = toStringOrEmpty(args.level) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const messages = await getConsoleMessagesViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
level,
|
||||
});
|
||||
res.json({ ok: true, messages, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_network_requests": {
|
||||
const includeStatic = toBoolean(args.includeStatic) ?? false;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const requests = await getNetworkRequestsViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
includeStatic,
|
||||
});
|
||||
res.json({ ok: true, requests, targetId: tab.targetId });
|
||||
return true;
|
||||
}
|
||||
case "browser_pdf_save": {
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const pdf = await pdfViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
pdf.buffer,
|
||||
"application/pdf",
|
||||
"browser",
|
||||
pdf.buffer.byteLength,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: path.resolve(saved.path),
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "browser_start_tracing": {
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await startTracingViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "browser_stop_tracing": {
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
const trace = await stopTracingViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
});
|
||||
await ensureMediaDir();
|
||||
const saved = await saveMediaBuffer(
|
||||
trace.buffer,
|
||||
"application/zip",
|
||||
"browser",
|
||||
trace.buffer.byteLength,
|
||||
);
|
||||
res.json({
|
||||
ok: true,
|
||||
path: path.resolve(saved.path),
|
||||
targetId: tab.targetId,
|
||||
url: tab.url,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
case "browser_verify_element_visible": {
|
||||
const role = toStringOrEmpty(args.role);
|
||||
const accessibleName = toStringOrEmpty(args.accessibleName);
|
||||
if (!role || !accessibleName) {
|
||||
jsonError(res, 400, "role and accessibleName are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await verifyElementVisibleViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
role,
|
||||
accessibleName,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "browser_verify_text_visible": {
|
||||
const text = toStringOrEmpty(args.text);
|
||||
if (!text) {
|
||||
jsonError(res, 400, "text is required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await verifyTextVisibleViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
text,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "browser_verify_list_visible": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
const items = toStringArray(args.items);
|
||||
if (!ref || !items?.length) {
|
||||
jsonError(res, 400, "ref and items are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await verifyListVisibleViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
items,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "browser_verify_value": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
const type = toStringOrEmpty(args.type);
|
||||
const value = toStringOrEmpty(args.value);
|
||||
if (!ref || !type) {
|
||||
jsonError(res, 400, "ref and type are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await verifyValueViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
ref,
|
||||
type,
|
||||
value,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "browser_mouse_move_xy": {
|
||||
const x = toNumber(args.x);
|
||||
const y = toNumber(args.y);
|
||||
if (x === undefined || y === undefined) {
|
||||
jsonError(res, 400, "x and y are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await mouseMoveViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
x,
|
||||
y,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "browser_mouse_click_xy": {
|
||||
const x = toNumber(args.x);
|
||||
const y = toNumber(args.y);
|
||||
if (x === undefined || y === undefined) {
|
||||
jsonError(res, 400, "x and y are required");
|
||||
return true;
|
||||
}
|
||||
const button = toStringOrEmpty(args.button) || undefined;
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await mouseClickViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
x,
|
||||
y,
|
||||
button,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "browser_mouse_drag_xy": {
|
||||
const startX = toNumber(args.startX);
|
||||
const startY = toNumber(args.startY);
|
||||
const endX = toNumber(args.endX);
|
||||
const endY = toNumber(args.endY);
|
||||
if (
|
||||
startX === undefined ||
|
||||
startY === undefined ||
|
||||
endX === undefined ||
|
||||
endY === undefined
|
||||
) {
|
||||
jsonError(res, 400, "startX, startY, endX, endY are required");
|
||||
return true;
|
||||
}
|
||||
const tab = await ctx.ensureTabAvailable(target);
|
||||
await mouseDragViaPlaywright({
|
||||
cdpPort,
|
||||
targetId: tab.targetId,
|
||||
startX,
|
||||
startY,
|
||||
endX,
|
||||
endY,
|
||||
});
|
||||
res.json({ ok: true });
|
||||
return true;
|
||||
}
|
||||
case "browser_generate_locator": {
|
||||
const ref = toStringOrEmpty(args.ref);
|
||||
if (!ref) {
|
||||
jsonError(res, 400, "ref is required");
|
||||
return true;
|
||||
}
|
||||
const locator = generateLocatorForRef(ref);
|
||||
res.json({ ok: true, locator });
|
||||
return true;
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
65
src/browser/routes/tool.ts
Normal file
65
src/browser/routes/tool.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
import type express from "express";
|
||||
|
||||
import type { BrowserRouteContext } from "../server-context.js";
|
||||
import { handleBrowserToolCore } from "./tool-core.js";
|
||||
import { handleBrowserToolExtra } from "./tool-extra.js";
|
||||
import { jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
type ToolRequestBody = {
|
||||
name?: unknown;
|
||||
args?: unknown;
|
||||
targetId?: unknown;
|
||||
};
|
||||
|
||||
function toolArgs(value: unknown): Record<string, unknown> {
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) return {};
|
||||
return value as Record<string, unknown>;
|
||||
}
|
||||
|
||||
export function registerBrowserToolRoutes(
|
||||
app: express.Express,
|
||||
ctx: BrowserRouteContext,
|
||||
) {
|
||||
app.post("/tool", async (req, res) => {
|
||||
const body = req.body as ToolRequestBody;
|
||||
const name = toStringOrEmpty(body?.name);
|
||||
if (!name) return jsonError(res, 400, "name is required");
|
||||
const args = toolArgs(body?.args);
|
||||
const targetId = toStringOrEmpty(body?.targetId || args?.targetId);
|
||||
|
||||
try {
|
||||
let cdpPort: number;
|
||||
try {
|
||||
cdpPort = ctx.state().cdpPort;
|
||||
} catch {
|
||||
return jsonError(res, 503, "browser server not started");
|
||||
}
|
||||
|
||||
const handledCore = await handleBrowserToolCore({
|
||||
name,
|
||||
args,
|
||||
targetId,
|
||||
cdpPort,
|
||||
ctx,
|
||||
res,
|
||||
});
|
||||
if (handledCore) return;
|
||||
|
||||
const handledExtra = await handleBrowserToolExtra({
|
||||
name,
|
||||
args,
|
||||
targetId,
|
||||
cdpPort,
|
||||
ctx,
|
||||
res,
|
||||
});
|
||||
if (handledExtra) return;
|
||||
|
||||
return jsonError(res, 400, "unknown tool name");
|
||||
} catch (err) {
|
||||
const mapped = ctx.mapTabError(err);
|
||||
if (mapped) return jsonError(res, mapped.status, mapped.message);
|
||||
jsonError(res, 500, String(err));
|
||||
}
|
||||
});
|
||||
}
|
||||
38
src/browser/routes/utils.ts
Normal file
38
src/browser/routes/utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import type express from "express";
|
||||
|
||||
export function jsonError(
|
||||
res: express.Response,
|
||||
status: number,
|
||||
message: string,
|
||||
) {
|
||||
res.status(status).json({ error: message });
|
||||
}
|
||||
|
||||
export function toStringOrEmpty(value: unknown) {
|
||||
return typeof value === "string" ? value.trim() : String(value ?? "").trim();
|
||||
}
|
||||
|
||||
export function toNumber(value: unknown) {
|
||||
if (typeof value === "number" && Number.isFinite(value)) return value;
|
||||
if (typeof value === "string" && value.trim()) {
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toBoolean(value: unknown) {
|
||||
if (typeof value === "boolean") return value;
|
||||
if (typeof value === "string") {
|
||||
const v = value.trim().toLowerCase();
|
||||
if (v === "true" || v === "1" || v === "yes") return true;
|
||||
if (v === "false" || v === "0" || v === "no") return false;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function toStringArray(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) return undefined;
|
||||
const strings = value.map((v) => toStringOrEmpty(v)).filter(Boolean);
|
||||
return strings.length ? strings : undefined;
|
||||
}
|
||||
Reference in New Issue
Block a user