repositories
loading repo index
repositories
loading repo index
repository
loading code, commits, and activity
public Clawd ADK gateway launch mirror
stars
latest
clone command
git clone gitlawb://did:key:z6Mkq5mY...iFZ5/my-project-publ...git clone gitlawb://did:key:z6Mkq5mY.../my-project-publ...2fa351d6docs: add automaton and perps launch sources16d ago| #1 | import { Hono } from 'hono'; |
| #2 | import type { AppEnv, MoltbotEnv } from '../types'; |
| #3 | import puppeteer, { type Browser, type Page } from '@cloudflare/puppeteer'; |
| #4 | |
| #5 | /** |
| #6 | * CDP (Chrome DevTools Protocol) WebSocket shim |
| #7 | * |
| #8 | * Implements a subset of the CDP protocol over WebSocket, translating commands |
| #9 | * to Cloudflare Browser Rendering binding calls (Puppeteer interface). |
| #10 | * |
| #11 | * Authentication: Pass secret as query param `?secret=<secret>` on WebSocket connect. |
| #12 | * This route is intentionally NOT protected by Cloudflare Access. |
| #13 | * |
| #14 | * Supported CDP domains: |
| #15 | * - Browser: getVersion, close |
| #16 | * - Target: createTarget, closeTarget, getTargets |
| #17 | * - Page: navigate, reload, getFrameTree, captureScreenshot, getLayoutMetrics |
| #18 | * - Runtime: evaluate |
| #19 | * - DOM: getDocument, querySelector, querySelectorAll, getOuterHTML, getAttributes |
| #20 | * - Input: dispatchMouseEvent, dispatchKeyEvent, insertText |
| #21 | * - Network: enable, disable, setCacheDisabled |
| #22 | * - Emulation: setDeviceMetricsOverride, setUserAgentOverride |
| #23 | */ |
| #24 | const cdp = new Hono<AppEnv>(); |
| #25 | |
| #26 | /** |
| #27 | * CDP Message types |
| #28 | */ |
| #29 | interface CDPRequest { |
| #30 | id: number; |
| #31 | method: string; |
| #32 | params?: Record<string, unknown>; |
| #33 | } |
| #34 | |
| #35 | interface CDPResponse { |
| #36 | id: number; |
| #37 | result?: unknown; |
| #38 | error?: { code: number; message: string }; |
| #39 | } |
| #40 | |
| #41 | interface CDPEvent { |
| #42 | method: string; |
| #43 | params?: Record<string, unknown>; |
| #44 | } |
| #45 | |
| #46 | /** |
| #47 | * Session state for a CDP connection |
| #48 | */ |
| #49 | interface CDPSession { |
| #50 | browser: Browser; |
| #51 | pages: Map<string, Page>; // targetId -> Page |
| #52 | defaultTargetId: string; |
| #53 | nodeIdCounter: number; |
| #54 | nodeMap: Map<number, string>; // nodeId -> selector path |
| #55 | objectIdCounter: number; |
| #56 | objectMap: Map<string, unknown>; // objectId -> value (for Runtime.getProperties) |
| #57 | scriptsToEvaluateOnNewDocument: Map<string, string>; // identifier -> source |
| #58 | extraHTTPHeaders: Map<string, string>; // header name -> value |
| #59 | requestInterceptionEnabled: boolean; |
| #60 | pendingRequests: Map<string, { request: Request; resolve: (response: Response) => void }>; |
| #61 | } |
| #62 | |
| #63 | /** |
| #64 | * GET /cdp - WebSocket upgrade endpoint |
| #65 | * |
| #66 | * Connect with: ws://host/cdp?secret=<CDP_SECRET> |
| #67 | */ |
| #68 | cdp.get('/', async (c) => { |
| #69 | // Check for WebSocket upgrade |
| #70 | const upgradeHeader = c.req.header('Upgrade'); |
| #71 | if (upgradeHeader?.toLowerCase() !== 'websocket') { |
| #72 | return c.json({ |
| #73 | error: 'WebSocket upgrade required', |
| #74 | hint: 'Connect via WebSocket: ws://host/cdp?secret=<CDP_SECRET>', |
| #75 | supported_methods: [ |
| #76 | // Browser |
| #77 | 'Browser.getVersion', |
| #78 | 'Browser.close', |
| #79 | // Target |
| #80 | 'Target.createTarget', |
| #81 | 'Target.closeTarget', |
| #82 | 'Target.getTargets', |
| #83 | 'Target.attachToTarget', |
| #84 | // Page |
| #85 | 'Page.navigate', |
| #86 | 'Page.reload', |
| #87 | 'Page.captureScreenshot', |
| #88 | 'Page.getFrameTree', |
| #89 | 'Page.getLayoutMetrics', |
| #90 | 'Page.bringToFront', |
| #91 | 'Page.setContent', |
| #92 | 'Page.printToPDF', |
| #93 | 'Page.addScriptToEvaluateOnNewDocument', |
| #94 | 'Page.removeScriptToEvaluateOnNewDocument', |
| #95 | 'Page.handleJavaScriptDialog', |
| #96 | 'Page.stopLoading', |
| #97 | 'Page.getNavigationHistory', |
| #98 | 'Page.navigateToHistoryEntry', |
| #99 | 'Page.setBypassCSP', |
| #100 | // Runtime |
| #101 | 'Runtime.evaluate', |
| #102 | 'Runtime.callFunctionOn', |
| #103 | 'Runtime.getProperties', |
| #104 | 'Runtime.releaseObject', |
| #105 | 'Runtime.releaseObjectGroup', |
| #106 | // DOM |
| #107 | 'DOM.getDocument', |
| #108 | 'DOM.querySelector', |
| #109 | 'DOM.querySelectorAll', |
| #110 | 'DOM.getOuterHTML', |
| #111 | 'DOM.getAttributes', |
| #112 | 'DOM.setAttributeValue', |
| #113 | 'DOM.focus', |
| #114 | 'DOM.getBoxModel', |
| #115 | 'DOM.scrollIntoViewIfNeeded', |
| #116 | 'DOM.removeNode', |
| #117 | 'DOM.setNodeValue', |
| #118 | 'DOM.setFileInputFiles', |
| #119 | // Input |
| #120 | 'Input.dispatchMouseEvent', |
| #121 | 'Input.dispatchKeyEvent', |
| #122 | 'Input.insertText', |
| #123 | // Network |
| #124 | 'Network.enable', |
| #125 | 'Network.disable', |
| #126 | 'Network.setCacheDisabled', |
| #127 | 'Network.setExtraHTTPHeaders', |
| #128 | 'Network.setCookie', |
| #129 | 'Network.setCookies', |
| #130 | 'Network.getCookies', |
| #131 | 'Network.deleteCookies', |
| #132 | 'Network.clearBrowserCookies', |
| #133 | 'Network.setUserAgentOverride', |
| #134 | // Fetch (Request Interception) |
| #135 | 'Fetch.enable', |
| #136 | 'Fetch.disable', |
| #137 | 'Fetch.continueRequest', |
| #138 | 'Fetch.fulfillRequest', |
| #139 | 'Fetch.failRequest', |
| #140 | 'Fetch.getResponseBody', |
| #141 | // Emulation |
| #142 | 'Emulation.setDeviceMetricsOverride', |
| #143 | 'Emulation.clearDeviceMetricsOverride', |
| #144 | 'Emulation.setUserAgentOverride', |
| #145 | 'Emulation.setGeolocationOverride', |
| #146 | 'Emulation.clearGeolocationOverride', |
| #147 | 'Emulation.setTimezoneOverride', |
| #148 | 'Emulation.setTouchEmulationEnabled', |
| #149 | 'Emulation.setEmulatedMedia', |
| #150 | 'Emulation.setDefaultBackgroundColorOverride', |
| #151 | ], |
| #152 | }); |
| #153 | } |
| #154 | |
| #155 | // Verify secret from query param |
| #156 | const url = new URL(c.req.url); |
| #157 | const providedSecret = url.searchParams.get('secret'); |
| #158 | const expectedSecret = c.env.CDP_SECRET; |
| #159 | |
| #160 | if (!expectedSecret) { |
| #161 | return c.json({ |
| #162 | error: 'CDP endpoint not configured', |
| #163 | hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', |
| #164 | }, 503); |
| #165 | } |
| #166 | |
| #167 | if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { |
| #168 | return c.json({ error: 'Unauthorized' }, 401); |
| #169 | } |
| #170 | |
| #171 | if (!c.env.BROWSER) { |
| #172 | return c.json({ |
| #173 | error: 'Browser Rendering not configured', |
| #174 | hint: 'Add browser binding to wrangler.jsonc', |
| #175 | }, 503); |
| #176 | } |
| #177 | |
| #178 | // Create WebSocket pair |
| #179 | const webSocketPair = new WebSocketPair(); |
| #180 | const [client, server] = Object.values(webSocketPair); |
| #181 | |
| #182 | // Accept the WebSocket |
| #183 | server.accept(); |
| #184 | |
| #185 | // Initialize CDP session asynchronously |
| #186 | initCDPSession(server, c.env).catch((err) => { |
| #187 | console.error('[CDP] Failed to initialize session:', err); |
| #188 | server.close(1011, 'Failed to initialize browser session'); |
| #189 | }); |
| #190 | |
| #191 | return new Response(null, { |
| #192 | status: 101, |
| #193 | webSocket: client, |
| #194 | }); |
| #195 | }); |
| #196 | |
| #197 | /** |
| #198 | * GET /json/version - CDP discovery endpoint |
| #199 | * |
| #200 | * Returns browser version info and WebSocket URL for Moltbot/Playwright compatibility. |
| #201 | * Authentication: Pass secret as query param `?secret=<CDP_SECRET>` |
| #202 | */ |
| #203 | cdp.get('/json/version', async (c) => { |
| #204 | // Verify secret from query param |
| #205 | const url = new URL(c.req.url); |
| #206 | const providedSecret = url.searchParams.get('secret'); |
| #207 | const expectedSecret = c.env.CDP_SECRET; |
| #208 | |
| #209 | if (!expectedSecret) { |
| #210 | return c.json({ |
| #211 | error: 'CDP endpoint not configured', |
| #212 | hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', |
| #213 | }, 503); |
| #214 | } |
| #215 | |
| #216 | if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { |
| #217 | return c.json({ error: 'Unauthorized' }, 401); |
| #218 | } |
| #219 | |
| #220 | if (!c.env.BROWSER) { |
| #221 | return c.json({ |
| #222 | error: 'Browser Rendering not configured', |
| #223 | hint: 'Add browser binding to wrangler.jsonc', |
| #224 | }, 503); |
| #225 | } |
| #226 | |
| #227 | // Build the WebSocket URL - preserve the secret in the WS URL |
| #228 | const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; |
| #229 | const wsUrl = `${wsProtocol}//${url.host}/cdp?secret=${encodeURIComponent(providedSecret)}`; |
| #230 | |
| #231 | return c.json({ |
| #232 | 'Browser': 'Cloudflare-Browser-Rendering/1.0', |
| #233 | 'Protocol-Version': '1.3', |
| #234 | 'User-Agent': 'Mozilla/5.0 Cloudflare Browser Rendering', |
| #235 | 'V8-Version': 'cloudflare', |
| #236 | 'WebKit-Version': 'cloudflare', |
| #237 | 'webSocketDebuggerUrl': wsUrl, |
| #238 | }); |
| #239 | }); |
| #240 | |
| #241 | /** |
| #242 | * GET /json/list - List available targets (tabs) |
| #243 | * |
| #244 | * Returns a list of available browser targets for Moltbot/Playwright compatibility. |
| #245 | * Note: Since we create targets on-demand per WebSocket connection, this returns |
| #246 | * a placeholder target that will be created when connecting. |
| #247 | * Authentication: Pass secret as query param `?secret=<CDP_SECRET>` |
| #248 | */ |
| #249 | cdp.get('/json/list', async (c) => { |
| #250 | // Verify secret from query param |
| #251 | const url = new URL(c.req.url); |
| #252 | const providedSecret = url.searchParams.get('secret'); |
| #253 | const expectedSecret = c.env.CDP_SECRET; |
| #254 | |
| #255 | if (!expectedSecret) { |
| #256 | return c.json({ |
| #257 | error: 'CDP endpoint not configured', |
| #258 | hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', |
| #259 | }, 503); |
| #260 | } |
| #261 | |
| #262 | if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { |
| #263 | return c.json({ error: 'Unauthorized' }, 401); |
| #264 | } |
| #265 | |
| #266 | if (!c.env.BROWSER) { |
| #267 | return c.json({ |
| #268 | error: 'Browser Rendering not configured', |
| #269 | hint: 'Add browser binding to wrangler.jsonc', |
| #270 | }, 503); |
| #271 | } |
| #272 | |
| #273 | // Build the WebSocket URL |
| #274 | const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; |
| #275 | const wsUrl = `${wsProtocol}//${url.host}/cdp?secret=${encodeURIComponent(providedSecret)}`; |
| #276 | |
| #277 | // Return a placeholder target - actual target is created on WS connect |
| #278 | return c.json([ |
| #279 | { |
| #280 | 'description': '', |
| #281 | 'devtoolsFrontendUrl': '', |
| #282 | 'id': 'cloudflare-browser', |
| #283 | 'title': 'Cloudflare Browser Rendering', |
| #284 | 'type': 'page', |
| #285 | 'url': 'about:blank', |
| #286 | 'webSocketDebuggerUrl': wsUrl, |
| #287 | }, |
| #288 | ]); |
| #289 | }); |
| #290 | |
| #291 | /** |
| #292 | * GET /json - Alias for /json/list (some clients use this) |
| #293 | */ |
| #294 | cdp.get('/json', async (c) => { |
| #295 | // Redirect internally to /json/list handler |
| #296 | const url = new URL(c.req.url); |
| #297 | url.pathname = url.pathname.replace(/\/json\/?$/, '/json/list'); |
| #298 | |
| #299 | // Verify secret from query param |
| #300 | const providedSecret = url.searchParams.get('secret'); |
| #301 | const expectedSecret = c.env.CDP_SECRET; |
| #302 | |
| #303 | if (!expectedSecret) { |
| #304 | return c.json({ |
| #305 | error: 'CDP endpoint not configured', |
| #306 | hint: 'Set CDP_SECRET via: wrangler secret put CDP_SECRET', |
| #307 | }, 503); |
| #308 | } |
| #309 | |
| #310 | if (!providedSecret || !timingSafeEqual(providedSecret, expectedSecret)) { |
| #311 | return c.json({ error: 'Unauthorized' }, 401); |
| #312 | } |
| #313 | |
| #314 | if (!c.env.BROWSER) { |
| #315 | return c.json({ |
| #316 | error: 'Browser Rendering not configured', |
| #317 | hint: 'Add browser binding to wrangler.jsonc', |
| #318 | }, 503); |
| #319 | } |
| #320 | |
| #321 | // Build the WebSocket URL |
| #322 | const wsProtocol = url.protocol === 'https:' ? 'wss:' : 'ws:'; |
| #323 | const wsUrl = `${wsProtocol}//${url.host}/cdp?secret=${encodeURIComponent(providedSecret)}`; |
| #324 | |
| #325 | return c.json([ |
| #326 | { |
| #327 | 'description': '', |
| #328 | 'devtoolsFrontendUrl': '', |
| #329 | 'id': 'cloudflare-browser', |
| #330 | 'title': 'Cloudflare Browser Rendering', |
| #331 | 'type': 'page', |
| #332 | 'url': 'about:blank', |
| #333 | 'webSocketDebuggerUrl': wsUrl, |
| #334 | }, |
| #335 | ]); |
| #336 | }); |
| #337 | |
| #338 | /** |
| #339 | * Initialize a CDP session for a WebSocket connection |
| #340 | */ |
| #341 | async function initCDPSession(ws: WebSocket, env: MoltbotEnv): Promise<void> { |
| #342 | let session: CDPSession | null = null; |
| #343 | |
| #344 | try { |
| #345 | // Launch browser |
| #346 | const browser = await puppeteer.launch(env.BROWSER!); |
| #347 | const page = await browser.newPage(); |
| #348 | const targetId = crypto.randomUUID(); |
| #349 | |
| #350 | session = { |
| #351 | browser, |
| #352 | pages: new Map([[targetId, page]]), |
| #353 | defaultTargetId: targetId, |
| #354 | nodeIdCounter: 1, |
| #355 | nodeMap: new Map(), |
| #356 | objectIdCounter: 1, |
| #357 | objectMap: new Map(), |
| #358 | scriptsToEvaluateOnNewDocument: new Map(), |
| #359 | extraHTTPHeaders: new Map(), |
| #360 | requestInterceptionEnabled: false, |
| #361 | pendingRequests: new Map(), |
| #362 | }; |
| #363 | |
| #364 | // Send initial target created event |
| #365 | sendEvent(ws, 'Target.targetCreated', { |
| #366 | targetInfo: { |
| #367 | targetId, |
| #368 | type: 'page', |
| #369 | title: '', |
| #370 | url: 'about:blank', |
| #371 | attached: true, |
| #372 | }, |
| #373 | }); |
| #374 | |
| #375 | console.log('[CDP] Session initialized, targetId:', targetId); |
| #376 | } catch (err) { |
| #377 | console.error('[CDP] Browser launch failed:', err); |
| #378 | ws.close(1011, 'Browser launch failed'); |
| #379 | return; |
| #380 | } |
| #381 | |
| #382 | // Handle incoming messages |
| #383 | ws.addEventListener('message', async (event) => { |
| #384 | if (!session) return; |
| #385 | |
| #386 | let request: CDPRequest; |
| #387 | try { |
| #388 | request = JSON.parse(event.data as string); |
| #389 | } catch { |
| #390 | console.error('[CDP] Invalid JSON received'); |
| #391 | return; |
| #392 | } |
| #393 | |
| #394 | console.log('[CDP] Request:', request.method, request.params); |
| #395 | |
| #396 | try { |
| #397 | const result = await handleCDPMethod(session, request.method, request.params || {}, ws); |
| #398 | sendResponse(ws, request.id, result); |
| #399 | } catch (err) { |
| #400 | console.error('[CDP] Method error:', request.method, err); |
| #401 | sendError(ws, request.id, -32000, err instanceof Error ? err.message : 'Unknown error'); |
| #402 | } |
| #403 | }); |
| #404 | |
| #405 | // Handle close |
| #406 | ws.addEventListener('close', async () => { |
| #407 | console.log('[CDP] WebSocket closed, cleaning up'); |
| #408 | if (session) { |
| #409 | try { |
| #410 | await session.browser.close(); |
| #411 | } catch (err) { |
| #412 | console.error('[CDP] Error closing browser:', err); |
| #413 | } |
| #414 | } |
| #415 | }); |
| #416 | |
| #417 | ws.addEventListener('error', (event) => { |
| #418 | console.error('[CDP] WebSocket error:', event); |
| #419 | }); |
| #420 | } |
| #421 | |
| #422 | /** |
| #423 | * Handle a CDP method call |
| #424 | */ |
| #425 | async function handleCDPMethod( |
| #426 | session: CDPSession, |
| #427 | method: string, |
| #428 | params: Record<string, unknown>, |
| #429 | ws: WebSocket |
| #430 | ): Promise<unknown> { |
| #431 | const [domain, command] = method.split('.'); |
| #432 | |
| #433 | // Get the current page (use targetId from params or default) |
| #434 | const targetId = (params.targetId as string) || session.defaultTargetId; |
| #435 | const page = session.pages.get(targetId); |
| #436 | |
| #437 | switch (domain) { |
| #438 | case 'Browser': |
| #439 | return handleBrowser(session, command, params); |
| #440 | |
| #441 | case 'Target': |
| #442 | return handleTarget(session, command, params, ws); |
| #443 | |
| #444 | case 'Page': |
| #445 | if (!page) throw new Error(`Target not found: ${targetId}`); |
| #446 | return handlePage(session, page, command, params, ws); |
| #447 | |
| #448 | case 'Runtime': |
| #449 | if (!page) throw new Error(`Target not found: ${targetId}`); |
| #450 | return handleRuntime(session, page, command, params); |
| #451 | |
| #452 | case 'DOM': |
| #453 | if (!page) throw new Error(`Target not found: ${targetId}`); |
| #454 | return handleDOM(session, page, command, params); |
| #455 | |
| #456 | case 'Input': |
| #457 | if (!page) throw new Error(`Target not found: ${targetId}`); |
| #458 | return handleInput(page, command, params); |
| #459 | |
| #460 | case 'Network': |
| #461 | return handleNetwork(session, page, command, params); |
| #462 | |
| #463 | case 'Emulation': |
| #464 | if (!page) throw new Error(`Target not found: ${targetId}`); |
| #465 | return handleEmulation(page, command, params); |
| #466 | |
| #467 | case 'Fetch': |
| #468 | if (!page) throw new Error(`Target not found: ${targetId}`); |
| #469 | return handleFetch(session, page, command, params, ws); |
| #470 | |
| #471 | default: |
| #472 | throw new Error(`Unknown domain: ${domain}`); |
| #473 | } |
| #474 | } |
| #475 | |
| #476 | /** |
| #477 | * Browser domain handlers |
| #478 | */ |
| #479 | async function handleBrowser( |
| #480 | session: CDPSession, |
| #481 | command: string, |
| #482 | _params: Record<string, unknown> |
| #483 | ): Promise<unknown> { |
| #484 | switch (command) { |
| #485 | case 'getVersion': |
| #486 | return { |
| #487 | protocolVersion: '1.3', |
| #488 | product: 'Cloudflare-Browser-Rendering', |
| #489 | revision: 'cloudflare', |
| #490 | userAgent: 'Mozilla/5.0 Cloudflare Browser Rendering', |
| #491 | jsVersion: 'V8', |
| #492 | }; |
| #493 | |
| #494 | case 'close': |
| #495 | await session.browser.close(); |
| #496 | return {}; |
| #497 | |
| #498 | default: |
| #499 | throw new Error(`Unknown Browser method: ${command}`); |
| #500 | } |
| #501 | } |
| #502 | |
| #503 | /** |
| #504 | * Target domain handlers |
| #505 | */ |
| #506 | async function handleTarget( |
| #507 | session: CDPSession, |
| #508 | command: string, |
| #509 | params: Record<string, unknown>, |
| #510 | ws: WebSocket |
| #511 | ): Promise<unknown> { |
| #512 | switch (command) { |
| #513 | case 'createTarget': { |
| #514 | const url = (params.url as string) || 'about:blank'; |
| #515 | const page = await session.browser.newPage(); |
| #516 | const targetId = crypto.randomUUID(); |
| #517 | |
| #518 | session.pages.set(targetId, page); |
| #519 | |
| #520 | if (url !== 'about:blank') { |
| #521 | await page.goto(url); |
| #522 | } |
| #523 | |
| #524 | sendEvent(ws, 'Target.targetCreated', { |
| #525 | targetInfo: { |
| #526 | targetId, |
| #527 | type: 'page', |
| #528 | title: await page.title(), |
| #529 | url: page.url(), |
| #530 | attached: true, |
| #531 | }, |
| #532 | }); |
| #533 | |
| #534 | return { targetId }; |
| #535 | } |
| #536 | |
| #537 | case 'closeTarget': { |
| #538 | const targetId = params.targetId as string; |
| #539 | const page = session.pages.get(targetId); |
| #540 | |
| #541 | if (!page) { |
| #542 | throw new Error(`Target not found: ${targetId}`); |
| #543 | } |
| #544 | |
| #545 | await page.close(); |
| #546 | session.pages.delete(targetId); |
| #547 | |
| #548 | sendEvent(ws, 'Target.targetDestroyed', { targetId }); |
| #549 | |
| #550 | return { success: true }; |
| #551 | } |
| #552 | |
| #553 | case 'getTargets': { |
| #554 | const targets = []; |
| #555 | for (const [targetId, page] of session.pages) { |
| #556 | targets.push({ |
| #557 | targetId, |
| #558 | type: 'page', |
| #559 | title: await page.title(), |
| #560 | url: page.url(), |
| #561 | attached: true, |
| #562 | }); |
| #563 | } |
| #564 | return { targetInfos: targets }; |
| #565 | } |
| #566 | |
| #567 | case 'attachToTarget': |
| #568 | // Already attached |
| #569 | return { sessionId: params.targetId }; |
| #570 | |
| #571 | default: |
| #572 | throw new Error(`Unknown Target method: ${command}`); |
| #573 | } |
| #574 | } |
| #575 | |
| #576 | /** |
| #577 | * Page domain handlers |
| #578 | */ |
| #579 | async function handlePage( |
| #580 | session: CDPSession, |
| #581 | page: Page, |
| #582 | command: string, |
| #583 | params: Record<string, unknown>, |
| #584 | ws: WebSocket |
| #585 | ): Promise<unknown> { |
| #586 | switch (command) { |
| #587 | case 'navigate': { |
| #588 | const url = params.url as string; |
| #589 | if (!url) throw new Error('url is required'); |
| #590 | |
| #591 | const response = await page.goto(url, { |
| #592 | waitUntil: 'load', |
| #593 | }); |
| #594 | |
| #595 | sendEvent(ws, 'Page.frameNavigated', { |
| #596 | frame: { |
| #597 | id: session.defaultTargetId, |
| #598 | url: page.url(), |
| #599 | securityOrigin: new URL(page.url()).origin, |
| #600 | mimeType: 'text/html', |
| #601 | }, |
| #602 | }); |
| #603 | |
| #604 | sendEvent(ws, 'Page.loadEventFired', { |
| #605 | timestamp: Date.now() / 1000, |
| #606 | }); |
| #607 | |
| #608 | return { |
| #609 | frameId: session.defaultTargetId, |
| #610 | loaderId: crypto.randomUUID(), |
| #611 | errorText: response?.ok() ? undefined : 'Navigation failed', |
| #612 | }; |
| #613 | } |
| #614 | |
| #615 | case 'reload': { |
| #616 | await page.reload(); |
| #617 | return {}; |
| #618 | } |
| #619 | |
| #620 | case 'getFrameTree': { |
| #621 | return { |
| #622 | frameTree: { |
| #623 | frame: { |
| #624 | id: session.defaultTargetId, |
| #625 | loaderId: crypto.randomUUID(), |
| #626 | url: page.url(), |
| #627 | securityOrigin: page.url() ? new URL(page.url()).origin : '', |
| #628 | mimeType: 'text/html', |
| #629 | }, |
| #630 | childFrames: [], |
| #631 | }, |
| #632 | }; |
| #633 | } |
| #634 | |
| #635 | case 'captureScreenshot': { |
| #636 | const format = (params.format as string) || 'png'; |
| #637 | const quality = params.quality as number | undefined; |
| #638 | const clip = params.clip as { x: number; y: number; width: number; height: number } | undefined; |
| #639 | |
| #640 | const data = await page.screenshot({ |
| #641 | type: format as 'png' | 'jpeg' | 'webp', |
| #642 | encoding: 'base64', |
| #643 | quality: format === 'jpeg' ? quality : undefined, |
| #644 | clip: clip, |
| #645 | fullPage: params.fullPage as boolean | undefined, |
| #646 | }); |
| #647 | |
| #648 | return { data }; |
| #649 | } |
| #650 | |
| #651 | case 'getLayoutMetrics': { |
| #652 | const metrics = await page.evaluate(() => ({ |
| #653 | width: document.documentElement.scrollWidth, |
| #654 | height: document.documentElement.scrollHeight, |
| #655 | clientWidth: document.documentElement.clientWidth, |
| #656 | clientHeight: document.documentElement.clientHeight, |
| #657 | })); |
| #658 | |
| #659 | return { |
| #660 | layoutViewport: { |
| #661 | pageX: 0, |
| #662 | pageY: 0, |
| #663 | clientWidth: metrics.clientWidth, |
| #664 | clientHeight: metrics.clientHeight, |
| #665 | }, |
| #666 | visualViewport: { |
| #667 | offsetX: 0, |
| #668 | offsetY: 0, |
| #669 | pageX: 0, |
| #670 | pageY: 0, |
| #671 | clientWidth: metrics.clientWidth, |
| #672 | clientHeight: metrics.clientHeight, |
| #673 | scale: 1, |
| #674 | }, |
| #675 | contentSize: { |
| #676 | x: 0, |
| #677 | y: 0, |
| #678 | width: metrics.width, |
| #679 | height: metrics.height, |
| #680 | }, |
| #681 | }; |
| #682 | } |
| #683 | |
| #684 | case 'bringToFront': |
| #685 | await page.bringToFront(); |
| #686 | return {}; |
| #687 | |
| #688 | case 'setContent': { |
| #689 | const html = params.html as string; |
| #690 | if (!html) throw new Error('html is required'); |
| #691 | |
| #692 | await page.setContent(html, { |
| #693 | waitUntil: (params.waitUntil as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2') || 'load', |
| #694 | }); |
| #695 | |
| #696 | return {}; |
| #697 | } |
| #698 | |
| #699 | case 'printToPDF': { |
| #700 | const options: Parameters<typeof page.pdf>[0] = {}; |
| #701 | |
| #702 | if (params.landscape) options.landscape = params.landscape as boolean; |
| #703 | if (params.displayHeaderFooter) options.displayHeaderFooter = params.displayHeaderFooter as boolean; |
| #704 | if (params.printBackground) options.printBackground = params.printBackground as boolean; |
| #705 | if (params.scale) options.scale = params.scale as number; |
| #706 | if (params.paperWidth) options.width = `${params.paperWidth}in`; |
| #707 | if (params.paperHeight) options.height = `${params.paperHeight}in`; |
| #708 | if (params.marginTop) options.margin = { ...options.margin, top: `${params.marginTop}in` }; |
| #709 | if (params.marginBottom) options.margin = { ...options.margin, bottom: `${params.marginBottom}in` }; |
| #710 | if (params.marginLeft) options.margin = { ...options.margin, left: `${params.marginLeft}in` }; |
| #711 | if (params.marginRight) options.margin = { ...options.margin, right: `${params.marginRight}in` }; |
| #712 | if (params.pageRanges) options.pageRanges = params.pageRanges as string; |
| #713 | if (params.headerTemplate) options.headerTemplate = params.headerTemplate as string; |
| #714 | if (params.footerTemplate) options.footerTemplate = params.footerTemplate as string; |
| #715 | if (params.preferCSSPageSize) options.preferCSSPageSize = params.preferCSSPageSize as boolean; |
| #716 | |
| #717 | const buffer = await page.pdf(options); |
| #718 | // Convert to base64 |
| #719 | const data = typeof buffer === 'string' ? buffer : Buffer.from(buffer).toString('base64'); |
| #720 | |
| #721 | return { data }; |
| #722 | } |
| #723 | |
| #724 | case 'addScriptToEvaluateOnNewDocument': { |
| #725 | const source = params.source as string; |
| #726 | if (!source) throw new Error('source is required'); |
| #727 | |
| #728 | const identifier = crypto.randomUUID(); |
| #729 | session.scriptsToEvaluateOnNewDocument.set(identifier, source); |
| #730 | |
| #731 | // Add to the page via evaluateOnNewDocument |
| #732 | await page.evaluateOnNewDocument(source); |
| #733 | |
| #734 | return { identifier }; |
| #735 | } |
| #736 | |
| #737 | case 'removeScriptToEvaluateOnNewDocument': { |
| #738 | const identifier = params.identifier as string; |
| #739 | session.scriptsToEvaluateOnNewDocument.delete(identifier); |
| #740 | // Note: Can't actually remove already-added scripts in Puppeteer |
| #741 | return {}; |
| #742 | } |
| #743 | |
| #744 | case 'handleJavaScriptDialog': { |
| #745 | const accept = params.accept as boolean; |
| #746 | const promptText = params.promptText as string | undefined; |
| #747 | |
| #748 | // Puppeteer auto-handles dialogs, but we can configure the page |
| #749 | page.on('dialog', async (dialog) => { |
| #750 | if (accept) { |
| #751 | await dialog.accept(promptText); |
| #752 | } else { |
| #753 | await dialog.dismiss(); |
| #754 | } |
| #755 | }); |
| #756 | |
| #757 | return {}; |
| #758 | } |
| #759 | |
| #760 | case 'stopLoading': { |
| #761 | await page.evaluate(() => window.stop()); |
| #762 | return {}; |
| #763 | } |
| #764 | |
| #765 | case 'getNavigationHistory': { |
| #766 | const history = await page.evaluate(() => ({ |
| #767 | currentIndex: window.history.length - 1, |
| #768 | entries: [{ |
| #769 | id: 0, |
| #770 | url: window.location.href, |
| #771 | userTypedURL: window.location.href, |
| #772 | title: document.title, |
| #773 | transitionType: 'typed', |
| #774 | }], |
| #775 | })); |
| #776 | |
| #777 | return history; |
| #778 | } |
| #779 | |
| #780 | case 'navigateToHistoryEntry': { |
| #781 | const entryId = params.entryId as number; |
| #782 | // Simple implementation - just go back/forward |
| #783 | await page.evaluate((id: number) => { |
| #784 | const delta = id - (window.history.length - 1); |
| #785 | window.history.go(delta); |
| #786 | }, entryId); |
| #787 | |
| #788 | return {}; |
| #789 | } |
| #790 | |
| #791 | case 'setBypassCSP': { |
| #792 | const enabled = params.enabled as boolean; |
| #793 | await page.setBypassCSP(enabled); |
| #794 | return {}; |
| #795 | } |
| #796 | |
| #797 | case 'enable': |
| #798 | case 'disable': |
| #799 | // No-op, events always enabled |
| #800 | return {}; |
| #801 | |
| #802 | default: |
| #803 | throw new Error(`Unknown Page method: ${command}`); |
| #804 | } |
| #805 | } |
| #806 | |
| #807 | /** |
| #808 | * Runtime domain handlers |
| #809 | */ |
| #810 | async function handleRuntime( |
| #811 | session: CDPSession, |
| #812 | page: Page, |
| #813 | command: string, |
| #814 | params: Record<string, unknown> |
| #815 | ): Promise<unknown> { |
| #816 | switch (command) { |
| #817 | case 'evaluate': { |
| #818 | const expression = params.expression as string; |
| #819 | if (!expression) throw new Error('expression is required'); |
| #820 | |
| #821 | const returnByValue = params.returnByValue ?? true; |
| #822 | const awaitPromise = params.awaitPromise ?? false; |
| #823 | |
| #824 | try { |
| #825 | // Wrap in async IIFE if awaitPromise is true |
| #826 | const wrappedExpression = awaitPromise |
| #827 | ? `(async () => { return ${expression}; })()` |
| #828 | : expression; |
| #829 | |
| #830 | const result = await page.evaluate(wrappedExpression); |
| #831 | |
| #832 | // Store object reference if not returning by value |
| #833 | let objectId: string | undefined; |
| #834 | if (!returnByValue && result !== null && typeof result === 'object') { |
| #835 | objectId = `obj-${session.objectIdCounter++}`; |
| #836 | session.objectMap.set(objectId, result); |
| #837 | } |
| #838 | |
| #839 | return { |
| #840 | result: { |
| #841 | type: typeof result, |
| #842 | subtype: Array.isArray(result) ? 'array' : (result === null ? 'null' : undefined), |
| #843 | className: result?.constructor?.name, |
| #844 | value: returnByValue ? result : undefined, |
| #845 | objectId, |
| #846 | description: String(result), |
| #847 | }, |
| #848 | }; |
| #849 | } catch (err) { |
| #850 | return { |
| #851 | exceptionDetails: { |
| #852 | exceptionId: 1, |
| #853 | text: err instanceof Error ? err.message : 'Evaluation failed', |
| #854 | lineNumber: 0, |
| #855 | columnNumber: 0, |
| #856 | }, |
| #857 | }; |
| #858 | } |
| #859 | } |
| #860 | |
| #861 | case 'callFunctionOn': { |
| #862 | const functionDeclaration = params.functionDeclaration as string; |
| #863 | const args = (params.arguments as Array<{ value?: unknown; objectId?: string }>) || []; |
| #864 | const returnByValue = params.returnByValue ?? true; |
| #865 | |
| #866 | try { |
| #867 | // Resolve object references in arguments |
| #868 | const argValues = args.map(a => { |
| #869 | if (a.objectId) { |
| #870 | return session.objectMap.get(a.objectId); |
| #871 | } |
| #872 | return a.value; |
| #873 | }); |
| #874 | |
| #875 | const fn = new Function(`return (${functionDeclaration}).apply(this, arguments)`); |
| #876 | const result = await page.evaluate(fn as () => unknown, ...argValues); |
| #877 | |
| #878 | let objectId: string | undefined; |
| #879 | if (!returnByValue && result !== null && typeof result === 'object') { |
| #880 | objectId = `obj-${session.objectIdCounter++}`; |
| #881 | session.objectMap.set(objectId, result); |
| #882 | } |
| #883 | |
| #884 | return { |
| #885 | result: { |
| #886 | type: typeof result, |
| #887 | subtype: Array.isArray(result) ? 'array' : (result === null ? 'null' : undefined), |
| #888 | value: returnByValue ? result : undefined, |
| #889 | objectId, |
| #890 | }, |
| #891 | }; |
| #892 | } catch (err) { |
| #893 | return { |
| #894 | exceptionDetails: { |
| #895 | exceptionId: 1, |
| #896 | text: err instanceof Error ? err.message : 'Call failed', |
| #897 | lineNumber: 0, |
| #898 | columnNumber: 0, |
| #899 | }, |
| #900 | }; |
| #901 | } |
| #902 | } |
| #903 | |
| #904 | case 'getProperties': { |
| #905 | const objectId = params.objectId as string; |
| #906 | const ownProperties = params.ownProperties ?? true; |
| #907 | |
| #908 | const obj = session.objectMap.get(objectId); |
| #909 | if (!obj || typeof obj !== 'object') { |
| #910 | return { result: [] }; |
| #911 | } |
| #912 | |
| #913 | const properties: Array<{ |
| #914 | name: string; |
| #915 | value: { type: string; value?: unknown; description?: string }; |
| #916 | writable?: boolean; |
| #917 | configurable?: boolean; |
| #918 | enumerable?: boolean; |
| #919 | isOwn?: boolean; |
| #920 | }> = []; |
| #921 | |
| #922 | const keys = ownProperties ? Object.getOwnPropertyNames(obj) : Object.keys(obj as object); |
| #923 | |
| #924 | for (const key of keys) { |
| #925 | const value = (obj as Record<string, unknown>)[key]; |
| #926 | const descriptor = Object.getOwnPropertyDescriptor(obj, key); |
| #927 | |
| #928 | properties.push({ |
| #929 | name: key, |
| #930 | value: { |
| #931 | type: typeof value, |
| #932 | value: value, |
| #933 | description: String(value), |
| #934 | }, |
| #935 | writable: descriptor?.writable, |
| #936 | configurable: descriptor?.configurable, |
| #937 | enumerable: descriptor?.enumerable, |
| #938 | isOwn: true, |
| #939 | }); |
| #940 | } |
| #941 | |
| #942 | return { result: properties }; |
| #943 | } |
| #944 | |
| #945 | case 'releaseObject': { |
| #946 | const objectId = params.objectId as string; |
| #947 | session.objectMap.delete(objectId); |
| #948 | return {}; |
| #949 | } |
| #950 | |
| #951 | case 'releaseObjectGroup': { |
| #952 | // Release all objects (simplified - we don't track groups) |
| #953 | session.objectMap.clear(); |
| #954 | return {}; |
| #955 | } |
| #956 | |
| #957 | case 'enable': |
| #958 | case 'disable': |
| #959 | return {}; |
| #960 | |
| #961 | default: |
| #962 | throw new Error(`Unknown Runtime method: ${command}`); |
| #963 | } |
| #964 | } |
| #965 | |
| #966 | /** |
| #967 | * DOM domain handlers |
| #968 | */ |
| #969 | async function handleDOM( |
| #970 | session: CDPSession, |
| #971 | page: Page, |
| #972 | command: string, |
| #973 | params: Record<string, unknown> |
| #974 | ): Promise<unknown> { |
| #975 | switch (command) { |
| #976 | case 'getDocument': { |
| #977 | const depth = (params.depth as number) ?? 1; |
| #978 | |
| #979 | // Get basic document structure |
| #980 | const doc = await page.evaluate((maxDepth: number) => { |
| #981 | function serializeNode(node: Node, currentDepth: number): unknown { |
| #982 | const base: Record<string, unknown> = { |
| #983 | nodeId: Math.floor(Math.random() * 1000000), |
| #984 | nodeType: node.nodeType, |
| #985 | nodeName: node.nodeName, |
| #986 | localName: node.nodeName.toLowerCase(), |
| #987 | nodeValue: node.nodeValue || '', |
| #988 | }; |
| #989 | |
| #990 | if (node instanceof Element) { |
| #991 | base.attributes = []; |
| #992 | for (const attr of node.attributes) { |
| #993 | (base.attributes as string[]).push(attr.name, attr.value); |
| #994 | } |
| #995 | |
| #996 | if (currentDepth < maxDepth && node.children.length > 0) { |
| #997 | base.children = []; |
| #998 | for (const child of node.children) { |
| #999 | (base.children as unknown[]).push(serializeNode(child, currentDepth + 1)); |
| #1000 | } |
| #1001 | base.childNodeCount = node.children.length; |
| #1002 | } else { |
| #1003 | base.childNodeCount = node.children.length; |
| #1004 | } |
| #1005 | } |
| #1006 | |
| #1007 | return base; |
| #1008 | } |
| #1009 | |
| #1010 | return serializeNode(document.documentElement, 0); |
| #1011 | }, depth); |
| #1012 | |
| #1013 | // Create a stable root nodeId |
| #1014 | const rootNodeId = session.nodeIdCounter++; |
| #1015 | session.nodeMap.set(rootNodeId, 'html'); |
| #1016 | |
| #1017 | return { |
| #1018 | root: { |
| #1019 | nodeId: rootNodeId, |
| #1020 | backendNodeId: rootNodeId, |
| #1021 | nodeType: 9, // Document |
| #1022 | nodeName: '#document', |
| #1023 | localName: '', |
| #1024 | nodeValue: '', |
| #1025 | childNodeCount: 1, |
| #1026 | children: [doc], |
| #1027 | documentURL: page.url(), |
| #1028 | baseURL: page.url(), |
| #1029 | }, |
| #1030 | }; |
| #1031 | } |
| #1032 | |
| #1033 | case 'querySelector': { |
| #1034 | const selector = params.selector as string; |
| #1035 | if (!selector) throw new Error('selector is required'); |
| #1036 | |
| #1037 | const element = await page.$(selector); |
| #1038 | if (!element) { |
| #1039 | return { nodeId: 0 }; |
| #1040 | } |
| #1041 | |
| #1042 | const nodeId = session.nodeIdCounter++; |
| #1043 | session.nodeMap.set(nodeId, selector); |
| #1044 | |
| #1045 | return { nodeId }; |
| #1046 | } |
| #1047 | |
| #1048 | case 'querySelectorAll': { |
| #1049 | const selector = params.selector as string; |
| #1050 | if (!selector) throw new Error('selector is required'); |
| #1051 | |
| #1052 | const elements = await page.$$(selector); |
| #1053 | const nodeIds = elements.map((_, i) => { |
| #1054 | const nodeId = session.nodeIdCounter++; |
| #1055 | session.nodeMap.set(nodeId, `${selector}:nth-of-type(${i + 1})`); |
| #1056 | return nodeId; |
| #1057 | }); |
| #1058 | |
| #1059 | return { nodeIds }; |
| #1060 | } |
| #1061 | |
| #1062 | case 'getOuterHTML': { |
| #1063 | const nodeId = params.nodeId as number; |
| #1064 | const selector = session.nodeMap.get(nodeId); |
| #1065 | |
| #1066 | if (!selector) { |
| #1067 | // Try to get document HTML |
| #1068 | const html = await page.content(); |
| #1069 | return { outerHTML: html }; |
| #1070 | } |
| #1071 | |
| #1072 | const html = await page.evaluate((sel: string) => { |
| #1073 | const el = document.querySelector(sel); |
| #1074 | return el ? el.outerHTML : ''; |
| #1075 | }, selector); |
| #1076 | |
| #1077 | return { outerHTML: html }; |
| #1078 | } |
| #1079 | |
| #1080 | case 'getAttributes': { |
| #1081 | const nodeId = params.nodeId as number; |
| #1082 | const selector = session.nodeMap.get(nodeId); |
| #1083 | |
| #1084 | if (!selector) throw new Error(`Node not found: ${nodeId}`); |
| #1085 | |
| #1086 | const attributes = await page.evaluate((sel: string) => { |
| #1087 | const el = document.querySelector(sel); |
| #1088 | if (!el) return []; |
| #1089 | const attrs: string[] = []; |
| #1090 | for (const attr of el.attributes) { |
| #1091 | attrs.push(attr.name, attr.value); |
| #1092 | } |
| #1093 | return attrs; |
| #1094 | }, selector); |
| #1095 | |
| #1096 | return { attributes }; |
| #1097 | } |
| #1098 | |
| #1099 | case 'setAttributeValue': { |
| #1100 | const nodeId = params.nodeId as number; |
| #1101 | const name = params.name as string; |
| #1102 | const value = params.value as string; |
| #1103 | const selector = session.nodeMap.get(nodeId); |
| #1104 | |
| #1105 | if (!selector) throw new Error(`Node not found: ${nodeId}`); |
| #1106 | |
| #1107 | await page.evaluate((sel: string, attrName: string, attrValue: string) => { |
| #1108 | const el = document.querySelector(sel); |
| #1109 | if (el) el.setAttribute(attrName, attrValue); |
| #1110 | }, selector, name, value); |
| #1111 | |
| #1112 | return {}; |
| #1113 | } |
| #1114 | |
| #1115 | case 'focus': { |
| #1116 | const nodeId = params.nodeId as number; |
| #1117 | const selector = session.nodeMap.get(nodeId); |
| #1118 | |
| #1119 | if (!selector) throw new Error(`Node not found: ${nodeId}`); |
| #1120 | |
| #1121 | await page.focus(selector); |
| #1122 | return {}; |
| #1123 | } |
| #1124 | |
| #1125 | case 'getBoxModel': { |
| #1126 | const nodeId = params.nodeId as number; |
| #1127 | const selector = session.nodeMap.get(nodeId); |
| #1128 | |
| #1129 | if (!selector) throw new Error(`Node not found: ${nodeId}`); |
| #1130 | |
| #1131 | const boxModel = await page.evaluate((sel: string) => { |
| #1132 | const el = document.querySelector(sel); |
| #1133 | if (!el) return null; |
| #1134 | |
| #1135 | const rect = el.getBoundingClientRect(); |
| #1136 | const scrollX = window.scrollX; |
| #1137 | const scrollY = window.scrollY; |
| #1138 | |
| #1139 | // Content box (innermost) |
| #1140 | const style = window.getComputedStyle(el); |
| #1141 | const paddingTop = parseFloat(style.paddingTop); |
| #1142 | const paddingRight = parseFloat(style.paddingRight); |
| #1143 | const paddingBottom = parseFloat(style.paddingBottom); |
| #1144 | const paddingLeft = parseFloat(style.paddingLeft); |
| #1145 | const borderTop = parseFloat(style.borderTopWidth); |
| #1146 | const borderRight = parseFloat(style.borderRightWidth); |
| #1147 | const borderBottom = parseFloat(style.borderBottomWidth); |
| #1148 | const borderLeft = parseFloat(style.borderLeftWidth); |
| #1149 | |
| #1150 | const content = { |
| #1151 | x: rect.left + scrollX + borderLeft + paddingLeft, |
| #1152 | y: rect.top + scrollY + borderTop + paddingTop, |
| #1153 | width: rect.width - borderLeft - borderRight - paddingLeft - paddingRight, |
| #1154 | height: rect.height - borderTop - borderBottom - paddingTop - paddingBottom, |
| #1155 | }; |
| #1156 | |
| #1157 | const padding = { |
| #1158 | x: rect.left + scrollX + borderLeft, |
| #1159 | y: rect.top + scrollY + borderTop, |
| #1160 | width: rect.width - borderLeft - borderRight, |
| #1161 | height: rect.height - borderTop - borderBottom, |
| #1162 | }; |
| #1163 | |
| #1164 | const border = { |
| #1165 | x: rect.left + scrollX, |
| #1166 | y: rect.top + scrollY, |
| #1167 | width: rect.width, |
| #1168 | height: rect.height, |
| #1169 | }; |
| #1170 | |
| #1171 | // Margin box |
| #1172 | const marginTop = parseFloat(style.marginTop); |
| #1173 | const marginRight = parseFloat(style.marginRight); |
| #1174 | const marginBottom = parseFloat(style.marginBottom); |
| #1175 | const marginLeft = parseFloat(style.marginLeft); |
| #1176 | |
| #1177 | const margin = { |
| #1178 | x: rect.left + scrollX - marginLeft, |
| #1179 | y: rect.top + scrollY - marginTop, |
| #1180 | width: rect.width + marginLeft + marginRight, |
| #1181 | height: rect.height + marginTop + marginBottom, |
| #1182 | }; |
| #1183 | |
| #1184 | return { content, padding, border, margin }; |
| #1185 | }, selector); |
| #1186 | |
| #1187 | if (!boxModel) { |
| #1188 | throw new Error(`Element not found: ${selector}`); |
| #1189 | } |
| #1190 | |
| #1191 | // Convert to quad format (4 points: top-left, top-right, bottom-right, bottom-left) |
| #1192 | const toQuad = (box: { x: number; y: number; width: number; height: number }) => [ |
| #1193 | box.x, box.y, |
| #1194 | box.x + box.width, box.y, |
| #1195 | box.x + box.width, box.y + box.height, |
| #1196 | box.x, box.y + box.height, |
| #1197 | ]; |
| #1198 | |
| #1199 | return { |
| #1200 | model: { |
| #1201 | content: toQuad(boxModel.content), |
| #1202 | padding: toQuad(boxModel.padding), |
| #1203 | border: toQuad(boxModel.border), |
| #1204 | margin: toQuad(boxModel.margin), |
| #1205 | width: boxModel.border.width, |
| #1206 | height: boxModel.border.height, |
| #1207 | }, |
| #1208 | }; |
| #1209 | } |
| #1210 | |
| #1211 | case 'scrollIntoViewIfNeeded': { |
| #1212 | const nodeId = params.nodeId as number; |
| #1213 | const selector = session.nodeMap.get(nodeId); |
| #1214 | |
| #1215 | if (!selector) throw new Error(`Node not found: ${nodeId}`); |
| #1216 | |
| #1217 | await page.evaluate((sel: string) => { |
| #1218 | const el = document.querySelector(sel); |
| #1219 | if (el) { |
| #1220 | el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'instant' }); |
| #1221 | } |
| #1222 | }, selector); |
| #1223 | |
| #1224 | return {}; |
| #1225 | } |
| #1226 | |
| #1227 | case 'removeNode': { |
| #1228 | const nodeId = params.nodeId as number; |
| #1229 | const selector = session.nodeMap.get(nodeId); |
| #1230 | |
| #1231 | if (!selector) throw new Error(`Node not found: ${nodeId}`); |
| #1232 | |
| #1233 | await page.evaluate((sel: string) => { |
| #1234 | const el = document.querySelector(sel); |
| #1235 | if (el && el.parentNode) { |
| #1236 | el.parentNode.removeChild(el); |
| #1237 | } |
| #1238 | }, selector); |
| #1239 | |
| #1240 | session.nodeMap.delete(nodeId); |
| #1241 | return {}; |
| #1242 | } |
| #1243 | |
| #1244 | case 'setNodeValue': { |
| #1245 | const nodeId = params.nodeId as number; |
| #1246 | const value = params.value as string; |
| #1247 | const selector = session.nodeMap.get(nodeId); |
| #1248 | |
| #1249 | if (!selector) throw new Error(`Node not found: ${nodeId}`); |
| #1250 | |
| #1251 | await page.evaluate((sel: string, val: string) => { |
| #1252 | const el = document.querySelector(sel); |
| #1253 | if (el) { |
| #1254 | el.textContent = val; |
| #1255 | } |
| #1256 | }, selector, value); |
| #1257 | |
| #1258 | return {}; |
| #1259 | } |
| #1260 | |
| #1261 | case 'setFileInputFiles': { |
| #1262 | const nodeId = params.nodeId as number; |
| #1263 | const files = params.files as string[]; |
| #1264 | const selector = session.nodeMap.get(nodeId); |
| #1265 | |
| #1266 | if (!selector) throw new Error(`Node not found: ${nodeId}`); |
| #1267 | |
| #1268 | const element = await page.$(selector); |
| #1269 | if (element) { |
| #1270 | // Cast to input element handle for uploadFile |
| #1271 | const inputElement = element as unknown as { uploadFile: (...paths: string[]) => Promise<void> }; |
| #1272 | await inputElement.uploadFile(...files); |
| #1273 | } |
| #1274 | |
| #1275 | return {}; |
| #1276 | } |
| #1277 | |
| #1278 | case 'enable': |
| #1279 | case 'disable': |
| #1280 | return {}; |
| #1281 | |
| #1282 | default: |
| #1283 | throw new Error(`Unknown DOM method: ${command}`); |
| #1284 | } |
| #1285 | } |
| #1286 | |
| #1287 | /** |
| #1288 | * Input domain handlers |
| #1289 | */ |
| #1290 | async function handleInput( |
| #1291 | page: Page, |
| #1292 | command: string, |
| #1293 | params: Record<string, unknown> |
| #1294 | ): Promise<unknown> { |
| #1295 | switch (command) { |
| #1296 | case 'dispatchMouseEvent': { |
| #1297 | const type = params.type as string; |
| #1298 | const x = params.x as number; |
| #1299 | const y = params.y as number; |
| #1300 | const button = (params.button as string) || 'left'; |
| #1301 | const clickCount = (params.clickCount as number) || 1; |
| #1302 | |
| #1303 | const mouse = page.mouse; |
| #1304 | |
| #1305 | switch (type) { |
| #1306 | case 'mousePressed': |
| #1307 | await mouse.down({ button: button as 'left' | 'right' | 'middle' }); |
| #1308 | break; |
| #1309 | case 'mouseReleased': |
| #1310 | await mouse.up({ button: button as 'left' | 'right' | 'middle' }); |
| #1311 | break; |
| #1312 | case 'mouseMoved': |
| #1313 | await mouse.move(x, y); |
| #1314 | break; |
| #1315 | case 'mouseWheel': |
| #1316 | await mouse.wheel({ deltaX: params.deltaX as number, deltaY: params.deltaY as number }); |
| #1317 | break; |
| #1318 | default: |
| #1319 | // For click, do move + down + up |
| #1320 | await mouse.click(x, y, { |
| #1321 | button: button as 'left' | 'right' | 'middle', |
| #1322 | clickCount, |
| #1323 | }); |
| #1324 | } |
| #1325 | |
| #1326 | return {}; |
| #1327 | } |
| #1328 | |
| #1329 | case 'dispatchKeyEvent': { |
| #1330 | const type = params.type as string; |
| #1331 | const key = params.key as string; |
| #1332 | const text = params.text as string; |
| #1333 | |
| #1334 | const keyboard = page.keyboard; |
| #1335 | |
| #1336 | // Type assertion needed as CDP uses string keys while Puppeteer uses KeyInput |
| #1337 | type KeyInput = Parameters<typeof keyboard.down>[0]; |
| #1338 | |
| #1339 | switch (type) { |
| #1340 | case 'keyDown': |
| #1341 | await keyboard.down(key as KeyInput); |
| #1342 | break; |
| #1343 | case 'keyUp': |
| #1344 | await keyboard.up(key as KeyInput); |
| #1345 | break; |
| #1346 | case 'char': |
| #1347 | if (text) await keyboard.type(text); |
| #1348 | break; |
| #1349 | default: |
| #1350 | if (key) await keyboard.press(key as KeyInput); |
| #1351 | } |
| #1352 | |
| #1353 | return {}; |
| #1354 | } |
| #1355 | |
| #1356 | case 'insertText': { |
| #1357 | const text = params.text as string; |
| #1358 | if (text) { |
| #1359 | await page.keyboard.type(text); |
| #1360 | } |
| #1361 | return {}; |
| #1362 | } |
| #1363 | |
| #1364 | default: |
| #1365 | throw new Error(`Unknown Input method: ${command}`); |
| #1366 | } |
| #1367 | } |
| #1368 | |
| #1369 | /** |
| #1370 | * Network domain handlers |
| #1371 | */ |
| #1372 | async function handleNetwork( |
| #1373 | session: CDPSession, |
| #1374 | page: Page | undefined, |
| #1375 | command: string, |
| #1376 | params: Record<string, unknown> |
| #1377 | ): Promise<unknown> { |
| #1378 | switch (command) { |
| #1379 | case 'enable': |
| #1380 | case 'disable': |
| #1381 | // Network events not fully supported, no-op |
| #1382 | return {}; |
| #1383 | |
| #1384 | case 'setCacheDisabled': { |
| #1385 | if (page) { |
| #1386 | await page.setCacheEnabled(!(params.cacheDisabled as boolean)); |
| #1387 | } |
| #1388 | return {}; |
| #1389 | } |
| #1390 | |
| #1391 | case 'setExtraHTTPHeaders': { |
| #1392 | const headers = params.headers as Record<string, string>; |
| #1393 | |
| #1394 | // Store headers in session |
| #1395 | session.extraHTTPHeaders.clear(); |
| #1396 | for (const [name, value] of Object.entries(headers)) { |
| #1397 | session.extraHTTPHeaders.set(name, value); |
| #1398 | } |
| #1399 | |
| #1400 | // Apply to page |
| #1401 | if (page) { |
| #1402 | await page.setExtraHTTPHeaders(headers); |
| #1403 | } |
| #1404 | |
| #1405 | return {}; |
| #1406 | } |
| #1407 | |
| #1408 | case 'setCookie': { |
| #1409 | if (!page) throw new Error('No page available'); |
| #1410 | |
| #1411 | const cookie = { |
| #1412 | name: params.name as string, |
| #1413 | value: params.value as string, |
| #1414 | url: params.url as string | undefined, |
| #1415 | domain: params.domain as string | undefined, |
| #1416 | path: params.path as string | undefined, |
| #1417 | secure: params.secure as boolean | undefined, |
| #1418 | httpOnly: params.httpOnly as boolean | undefined, |
| #1419 | sameSite: params.sameSite as 'Strict' | 'Lax' | 'None' | undefined, |
| #1420 | expires: params.expires as number | undefined, |
| #1421 | }; |
| #1422 | |
| #1423 | await page.setCookie(cookie); |
| #1424 | |
| #1425 | return { success: true }; |
| #1426 | } |
| #1427 | |
| #1428 | case 'setCookies': { |
| #1429 | if (!page) throw new Error('No page available'); |
| #1430 | |
| #1431 | const cookies = params.cookies as Array<{ |
| #1432 | name: string; |
| #1433 | value: string; |
| #1434 | url?: string; |
| #1435 | domain?: string; |
| #1436 | path?: string; |
| #1437 | secure?: boolean; |
| #1438 | httpOnly?: boolean; |
| #1439 | sameSite?: 'Strict' | 'Lax' | 'None'; |
| #1440 | expires?: number; |
| #1441 | }>; |
| #1442 | |
| #1443 | await page.setCookie(...cookies); |
| #1444 | |
| #1445 | return {}; |
| #1446 | } |
| #1447 | |
| #1448 | case 'getCookies': { |
| #1449 | if (!page) throw new Error('No page available'); |
| #1450 | |
| #1451 | const urls = params.urls as string[] | undefined; |
| #1452 | const cookies = await page.cookies(...(urls || [])); |
| #1453 | |
| #1454 | return { |
| #1455 | cookies: cookies.map(c => ({ |
| #1456 | name: c.name, |
| #1457 | value: c.value, |
| #1458 | domain: c.domain, |
| #1459 | path: c.path, |
| #1460 | expires: c.expires, |
| #1461 | size: c.name.length + c.value.length, |
| #1462 | httpOnly: c.httpOnly, |
| #1463 | secure: c.secure, |
| #1464 | session: c.session, |
| #1465 | sameSite: c.sameSite, |
| #1466 | })), |
| #1467 | }; |
| #1468 | } |
| #1469 | |
| #1470 | case 'deleteCookies': { |
| #1471 | if (!page) throw new Error('No page available'); |
| #1472 | |
| #1473 | const name = params.name as string; |
| #1474 | const url = params.url as string | undefined; |
| #1475 | const domain = params.domain as string | undefined; |
| #1476 | const path = params.path as string | undefined; |
| #1477 | |
| #1478 | await page.deleteCookie({ |
| #1479 | name, |
| #1480 | url, |
| #1481 | domain, |
| #1482 | path, |
| #1483 | }); |
| #1484 | |
| #1485 | return {}; |
| #1486 | } |
| #1487 | |
| #1488 | case 'clearBrowserCookies': { |
| #1489 | if (!page) throw new Error('No page available'); |
| #1490 | |
| #1491 | // Get all cookies and delete them |
| #1492 | const cookies = await page.cookies(); |
| #1493 | for (const cookie of cookies) { |
| #1494 | await page.deleteCookie(cookie); |
| #1495 | } |
| #1496 | |
| #1497 | return {}; |
| #1498 | } |
| #1499 | |
| #1500 | case 'setUserAgentOverride': { |
| #1501 | if (!page) throw new Error('No page available'); |
| #1502 | |
| #1503 | const userAgent = params.userAgent as string; |
| #1504 | await page.setUserAgent(userAgent); |
| #1505 | |
| #1506 | return {}; |
| #1507 | } |
| #1508 | |
| #1509 | default: |
| #1510 | throw new Error(`Unknown Network method: ${command}`); |
| #1511 | } |
| #1512 | } |
| #1513 | |
| #1514 | /** |
| #1515 | * Emulation domain handlers |
| #1516 | */ |
| #1517 | async function handleEmulation( |
| #1518 | page: Page, |
| #1519 | command: string, |
| #1520 | params: Record<string, unknown> |
| #1521 | ): Promise<unknown> { |
| #1522 | switch (command) { |
| #1523 | case 'setDeviceMetricsOverride': { |
| #1524 | const width = params.width as number; |
| #1525 | const height = params.height as number; |
| #1526 | const deviceScaleFactor = (params.deviceScaleFactor as number) || 1; |
| #1527 | const mobile = (params.mobile as boolean) || false; |
| #1528 | |
| #1529 | await page.setViewport({ |
| #1530 | width, |
| #1531 | height, |
| #1532 | deviceScaleFactor, |
| #1533 | isMobile: mobile, |
| #1534 | }); |
| #1535 | |
| #1536 | return {}; |
| #1537 | } |
| #1538 | |
| #1539 | case 'setUserAgentOverride': { |
| #1540 | const userAgent = params.userAgent as string; |
| #1541 | await page.setUserAgent(userAgent); |
| #1542 | return {}; |
| #1543 | } |
| #1544 | |
| #1545 | case 'clearDeviceMetricsOverride': |
| #1546 | // Reset to default |
| #1547 | await page.setViewport({ width: 1280, height: 720 }); |
| #1548 | return {}; |
| #1549 | |
| #1550 | case 'setGeolocationOverride': { |
| #1551 | const latitude = params.latitude as number | undefined; |
| #1552 | const longitude = params.longitude as number | undefined; |
| #1553 | const accuracy = params.accuracy as number | undefined; |
| #1554 | |
| #1555 | if (latitude !== undefined && longitude !== undefined) { |
| #1556 | await page.setGeolocation({ |
| #1557 | latitude, |
| #1558 | longitude, |
| #1559 | accuracy: accuracy ?? 100, |
| #1560 | }); |
| #1561 | } |
| #1562 | |
| #1563 | return {}; |
| #1564 | } |
| #1565 | |
| #1566 | case 'clearGeolocationOverride': { |
| #1567 | // Can't truly clear, but we can set to a default |
| #1568 | return {}; |
| #1569 | } |
| #1570 | |
| #1571 | case 'setTimezoneOverride': { |
| #1572 | const timezoneId = params.timezoneId as string; |
| #1573 | |
| #1574 | // Puppeteer doesn't have direct timezone override, but we can emulate via evaluate |
| #1575 | await page.evaluateOnNewDocument((tz: string) => { |
| #1576 | // Override Date to use the specified timezone |
| #1577 | const originalDate = Date; |
| #1578 | const originalToString = Date.prototype.toString; |
| #1579 | const originalToLocaleString = Date.prototype.toLocaleString; |
| #1580 | |
| #1581 | Date.prototype.toString = function() { |
| #1582 | return originalToLocaleString.call(this, 'en-US', { timeZone: tz }); |
| #1583 | }; |
| #1584 | |
| #1585 | // Store timezone for scripts that check it |
| #1586 | (globalThis as unknown as Record<string, string>).__timezone = tz; |
| #1587 | }, timezoneId); |
| #1588 | |
| #1589 | return {}; |
| #1590 | } |
| #1591 | |
| #1592 | case 'setTouchEmulationEnabled': { |
| #1593 | const enabled = params.enabled as boolean; |
| #1594 | |
| #1595 | // Puppeteer handles this via viewport isMobile, but we can also inject touch events |
| #1596 | if (enabled) { |
| #1597 | await page.evaluateOnNewDocument(() => { |
| #1598 | // Make the browser think it supports touch |
| #1599 | Object.defineProperty(navigator, 'maxTouchPoints', { |
| #1600 | get: () => 1, |
| #1601 | }); |
| #1602 | |
| #1603 | // Add touch event support indicator |
| #1604 | window.ontouchstart = null; |
| #1605 | }); |
| #1606 | } |
| #1607 | |
| #1608 | return {}; |
| #1609 | } |
| #1610 | |
| #1611 | case 'setEmulatedMedia': { |
| #1612 | const media = params.media as string | undefined; |
| #1613 | const features = params.features as Array<{ name: string; value: string }> | undefined; |
| #1614 | |
| #1615 | if (media) { |
| #1616 | await page.emulateMediaType(media as 'screen' | 'print'); |
| #1617 | } |
| #1618 | |
| #1619 | if (features) { |
| #1620 | await page.emulateMediaFeatures( |
| #1621 | features.map(f => ({ name: f.name, value: f.value })) |
| #1622 | ); |
| #1623 | } |
| #1624 | |
| #1625 | return {}; |
| #1626 | } |
| #1627 | |
| #1628 | case 'setDefaultBackgroundColorOverride': { |
| #1629 | const color = params.color as { r: number; g: number; b: number; a?: number } | undefined; |
| #1630 | |
| #1631 | if (color) { |
| #1632 | const { r, g, b, a = 1 } = color; |
| #1633 | await page.evaluate((rgba: string) => { |
| #1634 | document.documentElement.style.backgroundColor = rgba; |
| #1635 | }, `rgba(${r}, ${g}, ${b}, ${a})`); |
| #1636 | } |
| #1637 | |
| #1638 | return {}; |
| #1639 | } |
| #1640 | |
| #1641 | default: |
| #1642 | throw new Error(`Unknown Emulation method: ${command}`); |
| #1643 | } |
| #1644 | } |
| #1645 | |
| #1646 | /** |
| #1647 | * Fetch domain handlers (request interception) |
| #1648 | */ |
| #1649 | async function handleFetch( |
| #1650 | session: CDPSession, |
| #1651 | page: Page, |
| #1652 | command: string, |
| #1653 | params: Record<string, unknown>, |
| #1654 | ws: WebSocket |
| #1655 | ): Promise<unknown> { |
| #1656 | switch (command) { |
| #1657 | case 'enable': { |
| #1658 | const patterns = params.patterns as Array<{ urlPattern?: string; requestStage?: string }> | undefined; |
| #1659 | |
| #1660 | session.requestInterceptionEnabled = true; |
| #1661 | |
| #1662 | // Set up request interception |
| #1663 | await page.setRequestInterception(true); |
| #1664 | |
| #1665 | page.on('request', async (request) => { |
| #1666 | if (!session.requestInterceptionEnabled) { |
| #1667 | await request.continue(); |
| #1668 | return; |
| #1669 | } |
| #1670 | |
| #1671 | const requestId = crypto.randomUUID(); |
| #1672 | |
| #1673 | // Check if request matches patterns |
| #1674 | let shouldIntercept = !patterns || patterns.length === 0; |
| #1675 | if (patterns) { |
| #1676 | for (const pattern of patterns) { |
| #1677 | if (!pattern.urlPattern || request.url().match(pattern.urlPattern)) { |
| #1678 | shouldIntercept = true; |
| #1679 | break; |
| #1680 | } |
| #1681 | } |
| #1682 | } |
| #1683 | |
| #1684 | if (shouldIntercept) { |
| #1685 | // Store the request for later handling |
| #1686 | session.pendingRequests.set(requestId, { |
| #1687 | request: request as unknown as Request, |
| #1688 | resolve: () => {}, |
| #1689 | }); |
| #1690 | |
| #1691 | // Send Fetch.requestPaused event |
| #1692 | sendEvent(ws, 'Fetch.requestPaused', { |
| #1693 | requestId, |
| #1694 | request: { |
| #1695 | url: request.url(), |
| #1696 | method: request.method(), |
| #1697 | headers: request.headers(), |
| #1698 | postData: request.postData(), |
| #1699 | }, |
| #1700 | frameId: session.defaultTargetId, |
| #1701 | resourceType: request.resourceType(), |
| #1702 | }); |
| #1703 | } else { |
| #1704 | await request.continue(); |
| #1705 | } |
| #1706 | }); |
| #1707 | |
| #1708 | return {}; |
| #1709 | } |
| #1710 | |
| #1711 | case 'disable': { |
| #1712 | session.requestInterceptionEnabled = false; |
| #1713 | await page.setRequestInterception(false); |
| #1714 | return {}; |
| #1715 | } |
| #1716 | |
| #1717 | case 'continueRequest': { |
| #1718 | const requestId = params.requestId as string; |
| #1719 | const url = params.url as string | undefined; |
| #1720 | const method = params.method as string | undefined; |
| #1721 | const postData = params.postData as string | undefined; |
| #1722 | const headers = params.headers as Array<{ name: string; value: string }> | undefined; |
| #1723 | |
| #1724 | const pending = session.pendingRequests.get(requestId); |
| #1725 | if (!pending) { |
| #1726 | throw new Error(`Request not found: ${requestId}`); |
| #1727 | } |
| #1728 | |
| #1729 | const request = pending.request as unknown as { continue: (opts?: Record<string, unknown>) => Promise<void> }; |
| #1730 | |
| #1731 | const overrides: Record<string, unknown> = {}; |
| #1732 | if (url) overrides.url = url; |
| #1733 | if (method) overrides.method = method; |
| #1734 | if (postData) overrides.postData = postData; |
| #1735 | if (headers) { |
| #1736 | overrides.headers = headers.reduce((acc, h) => { |
| #1737 | acc[h.name] = h.value; |
| #1738 | return acc; |
| #1739 | }, {} as Record<string, string>); |
| #1740 | } |
| #1741 | |
| #1742 | await request.continue(Object.keys(overrides).length > 0 ? overrides : undefined); |
| #1743 | session.pendingRequests.delete(requestId); |
| #1744 | |
| #1745 | return {}; |
| #1746 | } |
| #1747 | |
| #1748 | case 'fulfillRequest': { |
| #1749 | const requestId = params.requestId as string; |
| #1750 | const responseCode = params.responseCode as number; |
| #1751 | const responseHeaders = params.responseHeaders as Array<{ name: string; value: string }> | undefined; |
| #1752 | const body = params.body as string | undefined; |
| #1753 | |
| #1754 | const pending = session.pendingRequests.get(requestId); |
| #1755 | if (!pending) { |
| #1756 | throw new Error(`Request not found: ${requestId}`); |
| #1757 | } |
| #1758 | |
| #1759 | const request = pending.request as unknown as { respond: (opts: Record<string, unknown>) => Promise<void> }; |
| #1760 | |
| #1761 | const headers: Record<string, string> = {}; |
| #1762 | if (responseHeaders) { |
| #1763 | for (const h of responseHeaders) { |
| #1764 | headers[h.name] = h.value; |
| #1765 | } |
| #1766 | } |
| #1767 | |
| #1768 | await request.respond({ |
| #1769 | status: responseCode, |
| #1770 | headers, |
| #1771 | body: body ? Buffer.from(body, 'base64') : undefined, |
| #1772 | }); |
| #1773 | |
| #1774 | session.pendingRequests.delete(requestId); |
| #1775 | |
| #1776 | return {}; |
| #1777 | } |
| #1778 | |
| #1779 | case 'failRequest': { |
| #1780 | const requestId = params.requestId as string; |
| #1781 | const errorReason = params.errorReason as string; |
| #1782 | |
| #1783 | const pending = session.pendingRequests.get(requestId); |
| #1784 | if (!pending) { |
| #1785 | throw new Error(`Request not found: ${requestId}`); |
| #1786 | } |
| #1787 | |
| #1788 | const request = pending.request as unknown as { abort: (reason?: string) => Promise<void> }; |
| #1789 | |
| #1790 | // Map CDP error reasons to Puppeteer abort reasons |
| #1791 | const abortReason = errorReason.toLowerCase().includes('access') ? 'accessdenied' : |
| #1792 | errorReason.toLowerCase().includes('address') ? 'addressunreachable' : |
| #1793 | errorReason.toLowerCase().includes('blocked') ? 'blockedbyclient' : |
| #1794 | errorReason.toLowerCase().includes('connection') ? 'connectionfailed' : |
| #1795 | errorReason.toLowerCase().includes('timeout') ? 'timedout' : |
| #1796 | 'failed'; |
| #1797 | |
| #1798 | await request.abort(abortReason); |
| #1799 | session.pendingRequests.delete(requestId); |
| #1800 | |
| #1801 | return {}; |
| #1802 | } |
| #1803 | |
| #1804 | case 'getResponseBody': { |
| #1805 | // This would need to store response bodies, which we're not currently doing |
| #1806 | // Return empty for now |
| #1807 | return { body: '', base64Encoded: false }; |
| #1808 | } |
| #1809 | |
| #1810 | default: |
| #1811 | throw new Error(`Unknown Fetch method: ${command}`); |
| #1812 | } |
| #1813 | } |
| #1814 | |
| #1815 | /** |
| #1816 | * Send a CDP response |
| #1817 | */ |
| #1818 | function sendResponse(ws: WebSocket, id: number, result: unknown): void { |
| #1819 | const response: CDPResponse = { id, result }; |
| #1820 | ws.send(JSON.stringify(response)); |
| #1821 | } |
| #1822 | |
| #1823 | /** |
| #1824 | * Send a CDP error |
| #1825 | */ |
| #1826 | function sendError(ws: WebSocket, id: number, code: number, message: string): void { |
| #1827 | const response: CDPResponse = { id, error: { code, message } }; |
| #1828 | ws.send(JSON.stringify(response)); |
| #1829 | } |
| #1830 | |
| #1831 | /** |
| #1832 | * Send a CDP event |
| #1833 | */ |
| #1834 | function sendEvent(ws: WebSocket, method: string, params?: Record<string, unknown>): void { |
| #1835 | const event: CDPEvent = { method, params }; |
| #1836 | ws.send(JSON.stringify(event)); |
| #1837 | } |
| #1838 | |
| #1839 | /** |
| #1840 | * Constant-time string comparison to prevent timing attacks |
| #1841 | */ |
| #1842 | function timingSafeEqual(a: string, b: string): boolean { |
| #1843 | if (a.length !== b.length) { |
| #1844 | return false; |
| #1845 | } |
| #1846 | |
| #1847 | let result = 0; |
| #1848 | for (let i = 0; i < a.length; i++) { |
| #1849 | result |= a.charCodeAt(i) ^ b.charCodeAt(i); |
| #1850 | } |
| #1851 | return result === 0; |
| #1852 | } |
| #1853 | |
| #1854 | export { cdp }; |
| #1855 |