{"id":50425320,"url":"https://github.com/pirate/modcdp","last_synced_at":"2026-05-31T10:02:33.693Z","repository":{"id":354936721,"uuid":"1220502812","full_name":"pirate/ModCDP","owner":"pirate","description":"Extend the Chrome Debug Protocol (CDP) with custom commands, events, and middlewares. Tap into and modify browser behavior, access chrome.* extension APIs, and change how drivers like stagehand/playwright/puppeteer work.","archived":false,"fork":false,"pushed_at":"2026-05-12T23:23:25.000Z","size":2897,"stargazers_count":1,"open_issues_count":1,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-05-12T23:31:35.199Z","etag":null,"topics":["browser-automation","cdp","chrome","chrome-debugger","chrome-debugger-protocol","chrome-debugging-protocol","chrome-extension","chrome-extensions","chromium","playwright","proxy","puppeteer"],"latest_commit_sha":null,"homepage":"","language":"TypeScript","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":null,"status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/pirate.png","metadata":{"files":{"readme":"README.md","changelog":null,"contributing":null,"funding":null,"license":null,"code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-04-25T01:11:18.000Z","updated_at":"2026-05-10T08:02:12.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/pirate/ModCDP","commit_stats":null,"previous_names":["pirate/chrome-cdp-extension-bridge","pirate/magic-cdp","pirate/cdpmod","pirate/modcdp"],"tags_count":0,"template":false,"template_full_name":null,"purl":"pkg:github/pirate/ModCDP","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pirate%2FModCDP","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pirate%2FModCDP/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pirate%2FModCDP/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pirate%2FModCDP/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/pirate","download_url":"https://codeload.github.com/pirate/ModCDP/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/pirate%2FModCDP/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33726719,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-31T02:00:06.040Z","response_time":95,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":["browser-automation","cdp","chrome","chrome-debugger","chrome-debugger-protocol","chrome-debugging-protocol","chrome-extension","chrome-extensions","chromium","playwright","proxy","puppeteer"],"created_at":"2026-05-31T10:02:32.913Z","updated_at":"2026-05-31T10:02:33.682Z","avatar_url":"https://github.com/pirate.png","language":"TypeScript","funding_links":[],"categories":[],"sub_categories":[],"readme":"# ModCDP\n\nCDP is powerful but it's been stretched to many use-cases beyond its initial audience. It is difficult for agents and humans to use without a harness library, because:\n\n- lacks the ability to use it statelessly without maintaining mappings of sessionIds, targetIds, frameIds, execution context IDs, backendNodeId ownership, and event listeners\n\n- lacks the ability to register custom CDP commands, abstractions, and events\n\n- lacks the ability to easily call chrome.\\* extension APIs for things like `chrome.tabs.query({ active: true })`\n\n- _lacks the ability to reference pages and elements with stable references across browser runs, such as XPath, URL, and frame index, instead of unstable identifiers like sessionId, targetId, frameId, backendNodeId_ (unrealistic dream? maybe not)\n\nWhile I had high hopes for WebDriver BiDi, unfortunately it solves almost none of these issues.\n\nModCDP does not aim to solve all of these issues directly either. Instead it solves a simpler problem: allowing us to customize and extend CDP with new commands.\nThen we use those basic primitives to fix the shortcomings in CDP by implementing our own custom events (all sent over a normal CDP websocket to a stock Chromium browser).\n\n| Primitive              | What it does                                                                                          |\n| ---------------------- | ----------------------------------------------------------------------------------------------------- |\n| `Mod.evaluate`         | Run an expression in the ModCDP extension service worker, with `chrome.*` and a `cdp` bridge in scope |\n| `Mod.addCustomCommand` | Register a `Custom.*` method handler that lives in the SW                                             |\n| `Mod.addCustomEvent`   | Register a `Custom.*` event your SW handlers can `emit()`                                             |\n| `Mod.addMiddleware`    | Intercept service-worker-routed requests, responses, or events by name or `*`                         |\n\nInstead of inventing yet another browser driver library, ModCDP fixes the issue at the root.\n\nIt's perfectly compatible with playwright, puppeteer, etc. with no modifications. You can do things like `patchright` does, but generically at the CDP layer instead of having to patch libraries.\n\nYou can send `Mod.*`, `Custom.*`, etc. through standard Playwright/Puppeteer/other-driver-managed CDP sessions; `js/examples/demo.ts`, `python/examples/demo.py`, and `go/examples/demo/main.go` demonstrate the flow in each language.\n\n## Use it\n\n```ts\nimport { ModCDPClient } from \"modcdp\";\nimport { z } from \"zod\";\n\nconst upstream_cdp_url = \"http://127.0.0.1:9222\"; // host:port, http(s), and ws(s) URLs work\nconst cdp = new ModCDPClient({\n  launcher: { launcher_mode: \"remote\" },\n  upstream: { upstream_mode: \"ws\", upstream_cdp_url },\n  injector: { injector_mode: \"auto\" },\n  client: { client_routes: { \"Target.getTargets\": \"service_worker\" } },\n  server: { server_loopback_cdp_url: upstream_cdp_url, server_routes: { \"*.*\": \"loopback_cdp\" } },\n});\nawait cdp.connect();\n\n// use it like a normal CDP connection, send normal CDP, register for normal CDP events\nconsole.log(await cdp.Browser.getVersion());\ncdp.on(cdp.Target.targetInfoChanged, console.log);\n\n// run extension code with chrome.* in scope\nconst tab = await cdp.Mod.evaluate({\n  expression: \"(await chrome.tabs.query({ active: true }))[0]\",\n});\n\n// ✨ register and use custom CDP commands\nawait cdp.Mod.addCustomCommand({\n  name: \"Custom.tabIdFromTargetId\",\n  params_schema: { targetId: cdp.types.zod.Target.TargetID },\n  result_schema: { tabId: z.number().nullable() },\n  expression: `async ({ targetId }) =\u003e ({\n    tabId: (await chrome.debugger.getTargets()).find(t =\u003e t.id === targetId)?.tabId ?? null\n  })`,\n});\nconst { targetInfos } = await cdp.Target.getTargets();\nconst pageTarget = targetInfos.find((targetInfo) =\u003e targetInfo.type === \"page\");\nconsole.log(await cdp.send(\"Custom.tabIdFromTargetId\", { targetId: pageTarget.targetId })); // -\u003e { tabId: 22352432 }\n\n// ✨ set up new custom CDP events to fire + receive them just like normal CDP\n// this example sets up a truly accurate \"foreground focus\" tracking event,\n// which CDP doesn't have natively https://issues.chromium.org/issues/497896141\nconst PageForegroundPageChanged = z\n  .object({\n    targetId: cdp.types.zod.Target.TargetID.nullable(),\n    tabId: z.number(),\n  })\n  .passthrough()\n  .meta({ id: \"Page.foregroundPageChanged\" });\n\nawait cdp.Mod.addCustomEvent(PageForegroundPageChanged);\nawait cdp.Mod.evaluate({\n  expression: `chrome.tabs.onActivated.addListener(async ({ tabId }) =\u003e\n    cdp.emit(\"Page.foregroundPageChanged\", {\n      tabId,\n      targetId: (await chrome.debugger.getTargets()).find(t =\u003e t.tabId === tabId)?.id ?? null\n    })\n  )`,\n});\ncdp.on(PageForegroundPageChanged, console.log);\n\n// ✨ Intercept, modify, and extend existing CDP commands/results/events on the wire\nawait cdp.Mod.addMiddleware({\n  name: cdp.Target.getTargets,\n  phase: cdp.RESPONSE,\n  // attach .tabId next to every .targetId in events browser emits\n  expression: `async (payload, next) =\u003e {\n    for (const targetInfo of payload.targetInfos) {\n      const { tabId } = await cdp.send(\"Custom.tabIdFromTargetId\", {\n        targetId: targetInfo.targetId,\n      });\n      targetInfo.tabId = tabId;\n    }\n    return next(payload);\n  }`,\n});\nconsole.log(await cdp.Target.getTargets()); // TargetInfo entries now include tabId\n\n// typed + zod-enforced imperative aliases are generated for standard CDP too\nconst created = await cdp.Target.createTarget({ url: \"https://example.com\" });\nawait cdp.Target.activateTarget({ targetId: created.targetId }); // triggers Page.foregroundPageChanged\nconsole.log(created);\n```\n\n## Run the demos\n\nEach demo launches Chrome with the fixed ModCDP extension artifact loaded, headful on macOS and `--headless=new` on Linux, then exercises raw CDP, `Mod.evaluate`, `Custom.*` commands, custom events, middleware, and latency reporting in the chosen mode. When stdin is a TTY, the demo leaves you in the same mini REPL/TUI across JS, Python, and Go.\n\n```sh\npnpm run demo:js                    # defaults to --loopback --upstream=ws\npnpm run demo:js -- --debugger --upstream=pipe\npnpm run demo:js -- --loopback --upstream=reversews\npnpm run demo:js -- --loopback --upstream=nativemessaging\npnpm run demo:python\npnpm run demo:go\n```\n\nThe Python package is managed with `uv`; `pnpm run demo:python` runs the built demo through `uv` and does not require a separate `pip install`. Set `CHROME_PATH=/path/to/chromium` to force a specific browser binary.\n\n## Transparent Proxy\n\nUpgrade any vanilla CDP client like Stagehand, Playwright, or Puppeteer transparently with support for `Mod.*` / `Custom.*` commands and events.\n\n```sh\npnpm run proxy -- --upstream-mode=ws --upstream-cdp-url=http://127.0.0.1:9222 --port 9223\npnpm run proxy -- --launcher-mode=local --upstream-mode=pipe --port 9223\npnpm run proxy -- --launcher-mode=local --upstream-mode=nativemessaging --port 9223\npnpm run proxy -- --launcher-mode=local --upstream-mode=nats --upstream-nats-url=ws://127.0.0.1:4223 --port 9223\n# const browser = await playwright.chromium.connectOverCDP(\"http://127.0.0.1:9223\")\n# const session = await browser.contexts()[0].newCDPSession(page)\n# await session.send(\"Mod.evaluate\", { expression: \"1 + 1\" }) // -\u003e 2\n# ✨ All ModCDP commands now work through playwright! you can modify/extend playwright behavior to your heart's content\n```\n\nThe proxy uses the same `--launcher-*`, `--injector-*`, `--upstream-*`, `--client='{\"client_routes\": {...}}'`, and `--server='{\"server_routes\": {...}}'` option groups as `ModCDPClient`. `--launcher-options='{...}'` passes launcher-owned options such as `headless` and `sandbox`; `--client-routes='{...}'` and `--server-routes='{...}'` are route-only shorthands. `ws` keeps a transparent websocket-to-websocket fast path; `pipe`, `nativemessaging`, `nats`, and launched `reversews` proxy downstream CDP-shaped messages through the selected `ModCDPClient` upstream transport.\n\nNative messaging mode creates the local native host wrapper and browser manifest on each run. The fixed extension auto-dials the default `com.modcdp.bridge` host, so the 1:1 automatic path does not require preinstalling a host binary at a fixed path. `--upstream-nativemessaging-manifest`, `--upstream-nativemessaging-manifests`, and `--upstream-nativemessaging-host-name` are for custom browser profiles, externally configured extensions, or isolated transport-level tests; the default auto-injected extension expects `com.modcdp.bridge`.\n\n### Reverse proxy mode\n\nUse reverse mode when the browser does not expose a public CDP websocket to the final client, but the ModCDP extension can open a websocket back to a local proxy. The proxy still serves a normal-looking CDP endpoint to Playwright, Puppeteer, Stagehand, or any other CDP client:\n\n```sh\npnpm run proxy -- --upstream-mode=reversews --port 9223\npnpm run proxy -- --launcher-mode=local --upstream-mode=reversews --port 9223\npnpm run proxy -- --launcher-mode=local --upstream-mode=reversews --upstream-reversews-bind=127.0.0.1:29293 --port 9223\n# const browser = await playwright.chromium.connectOverCDP(\"http://127.0.0.1:9223\")\n```\n\nReverse mode is opt-in. The shipped extension auto-connects to the fixed local reverse connector at `ws://127.0.0.1:29292`; the proxy/client listens there and waits for that extension connection. Keep `--upstream-reversews-bind` when using a custom extension build whose compiled autoconnect URL points at a different host or port. `--upstream-reversews-wait-timeout-ms` controls how long the proxy/client waits. Once connected, the extension identifies itself as a ModCDP service worker and the proxy uses that reverse websocket as its upstream. `Mod.*`, expression-backed `Custom.*` commands, custom event fanout, middleware, and normal CDP commands all stay routed through `globalThis.ModCDP.handleCommand(...)` in the service worker.\n\nReverse mode is intentionally scoped to one local browser and one reverse extension connection per proxy process. The browser may still have other extensions installed; ModCDP does not require `--disable-extensions-except`.\n\n## Routing modes\n\n`Mod.*` and `Custom.*` always go through the extension service worker. Routing only changes how _standard_ CDP methods (`Browser.*`, `Page.*`, `DOM.*`, …) are serviced:\n\n| Demo CLI Flag | Standard CDP path                                                  | Use when                                                                        |\n| ------------- | ------------------------------------------------------------------ | ------------------------------------------------------------------------------- |\n| `--loopback`  | client → SW → SW dials its own WS back to localhost:9222 → CDP     | Default. You need the SW to intercept/inspect/rewrite normal traffic.           |\n| `--debugger`  | client → SW → `chrome.debugger.sendCommand` against the active tab | The browser exposes no remote CDP port and you only have extension permissions. |\n| `--direct`    | client → sends non-ModCDP commands to browser CDP directly         | You already have a CDP endpoint and don't need extension interception.          |\n\nPass via `client: { client_routes: { \"*.*\": \"direct_cdp\" | \"service_worker\" } }` and `server: { server_routes: { \"*.*\": \"loopback_cdp\" | \"chrome_debugger\" } }`. The demos default to `--loopback` (the most powerful mode).\n\n## Repository layout\n\n```\nextension/                MV3 extension shell and static pages\n  manifest.json\n  src/\n  pages/\njs/                       TypeScript client/server/proxy package\n  src/\n    client/\n    server/\n    launcher/\n    injector/\n    transport/\n    router/\n    translate/\n    proxy/\n    types/\n  examples/\n  test/\npython/                   Python package, examples, and tests\n  modcdp/\n    client/\n    launcher/\n    injector/\n    transport/\n    router/\n    translate/\n    types/\ngo/                       Go module, examples, and tests\n  modcdp/\n    client/\n    launcher/\n    injector/\n    transport/\n    router/\n    translate/\n    types/\ndist/                     Built JS output used by the extension and Node CLI scripts\n```\n\n## Requirements\n\n- Stock Google Chrome can be used without relaunch flags: visit `chrome://inspect/#remote-debugging` to expose the current browser at `http://127.0.0.1:9222`, and load/install the ModCDP extension in that profile. Pass that endpoint as `upstream: { upstream_mode: \"ws\", upstream_cdp_url: \"http://127.0.0.1:9222\" }`.\n- Automated/test browsers can still preload the extension with `--load-extension=\u003cpath\u003e`. `Extensions.loadUnpacked` is used as a fallback when the connected browser exposes it over CDP.\n- Node ≥ 22, Python ≥ 3.11 with `websocket-client`, Go ≥ 1.25 with `gobwas/ws`.\n\n---\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eArchitecture \u0026amp; lifecycle\u003c/b\u003e\u003c/summary\u003e\n\n### Connect\n\n1. Select a `launcher` class and an `upstream` transport. `launcher.launcher_mode=\"local\"` starts a local browser, `launcher.launcher_mode=\"remote\"` uses the supplied `upstream.upstream_cdp_url`, and `launcher.launcher_mode=\"none\"` leaves browser lifecycle outside ModCDP.\n2. The configured extension injector classes try discovery, launch-arg injection, Browserbase upload, `Extensions.loadUnpacked`, or borrowing in the configured order.\n3. Attach a session to that SW target and `Runtime.enable` on it.\n4. Call `globalThis.ModCDP.configure(...)` to push the resolved loopback websocket and any explicit server route overrides into the SW. The clients do this automatically by default.\n\nReverse proxy mode flips the bootstrap direction: `js/src/proxy/proxy.ts --upstream-mode=reversews --port 9223` listens for the extension on the configured reverse connector while still serving downstream clients from the proxy port. The service worker sends a `modcdp.reverse.hello` message and then accepts CDP-shaped command messages from the proxy. The proxy maps downstream request IDs to reverse request IDs and forwards reverse events back to the downstream CDP client.\n\n### Send\n\n- `Mod.evaluate({ expression, params, cdpSessionId })` → `Runtime.evaluate` on the ext session, wrapping the expression with an IIFE that exposes `params` and `cdp = ModCDP.attachToSession(...)`.\n- `Mod.addCustomCommand({ name, expression, ... })` → `Runtime.evaluate` calling `globalThis.ModCDP.addCustomCommand({ ... })` with the user expression embedded as the handler.\n- `Mod.addCustomEvent(EventSchema.meta({ id }))` → `Runtime.evaluate` registering the event in `globalThis.ModCDP`; all custom events are delivered through the single `__ModCDP_custom_event__` binding installed at connect time.\n- `Mod.addMiddleware({ name, phase, expression })` → `Runtime.evaluate` registering a service-worker middleware for `phase: \"request\" | \"response\" | \"event\"`. Use `name: \"*\"` to match every method/event in that phase, or pass generated names like `cdp.Target.targetInfoChanged`.\n- `Custom.X(params)` → `Runtime.evaluate` calling `globalThis.ModCDP.handleCommand(\"Custom.X\", params, cdpSessionId)`.\n\nIn reverse mode, expression-backed commands cannot use `new Function` directly because MV3 extension CSP blocks unsafe eval. The service worker evaluates user expressions by attaching `chrome.debugger` to its own service-worker target and issuing `Runtime.evaluate`, preserving the normal ModCDP expression surface without weakening extension CSP.\n\n### Receive\n\nWhen SW handlers `cdp.emit('Custom.X', payload)`, the SW invokes `globalThis.__ModCDP_custom_event__(JSON.stringify({ event, data, cdpSessionId }))`. CDP delivers `Runtime.bindingCalled` on the ext session; the client (or proxy) decodes the payload and re-dispatches it as a normal `cdp.on('Custom.X', ...)` event.\n\nIn reverse mode, the same `publishEvent(...)` path also sends CDP-shaped event messages over the reverse websocket, so custom events and mirrored upstream events fan out through the standalone proxy to the downstream client.\n\n### Why this works\n\n`Runtime.addBinding` is the only out-of-page → in-page → out-of-page channel CDP exposes. Combined with one extension service worker (which gets `chrome.*` access as a side effect of being in an extension), you get:\n\n- A guaranteed JS execution context that's not a page, with the right permissions\n- A way to push named events back through the same CDP socket your client already speaks\n- The same command/event surface whether the bytes arrive by websocket, pipe, native messaging, reverse websocket, or NATS.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eRouting details\u003c/b\u003e\u003c/summary\u003e\n\n```ts\ntype CDPUpstream = \"service_worker\" | \"direct_cdp\" | \"auto\" | \"loopback_cdp\" | \"chrome_debugger\";\n\n// client-side defaults\nconst client_routes = { \"Mod.*\": \"service_worker\", \"Custom.*\": \"service_worker\", \"*.*\": \"service_worker\" } as const;\n\n// server-side defaults (inside the SW)\nconst server_routes = { \"Mod.*\": \"service_worker\", \"Custom.*\": \"service_worker\", \"*.*\": \"auto\" } as const;\n```\n\n- **`service_worker`** — handle in the extension SW.\n- **`direct_cdp`** (client only) — send straight to the browser CDP websocket.\n- **`auto`** (server only) — try `loopback_cdp` first, fall back to `chrome_debugger`.\n- **`loopback_cdp`** (server only) — SW dials a CDP websocket reachable from the browser. You may pass `http://host:port` as shorthand, but it is resolved to the concrete `ws://.../devtools/...` URL at configuration time. Useful for `Browser.*` commands that `chrome.debugger` doesn't support.\n- **`chrome_debugger`** (server only) — `chrome.debugger.sendCommand` against `params.debuggee || { tabId, targetId, extensionId }`, defaulting to the active last-focused tab.\n\nRoute resolution is **deterministic across all three language clients**: exact-method match → longest-prefix wildcard → `*.*` fallback. This avoids map-iteration nondeterminism (Go) and key-insertion-order shadowing (JS/Python).\n\nWhen server-side `auto` routing tries loopback CDP discovery, the SW only trusts `127.0.0.1:9222` after verifying a per-connection `server.server_browser_token` against its own service-worker target. It will not accidentally route loopback commands through a different browser that happens to have the same extension installed.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eWire diagrams\u003c/b\u003e\u003c/summary\u003e\n\n#### 1. Normal CDP Call / Response\n\n```mermaid\nflowchart LR\n  subgraph Node[\"Node client\"]\n    direction LR\n    SDK[\"SDK\"]\n    WS[\"WS client\"]\n    SDK --\u003e|\"1. cdp.send('Browser.getVersion')\"| WS\n  end\n\n  subgraph Browser[\"Browser\"]\n    direction LR\n    CDP[\"CDP router\u003cbr/\u003elocalhost:9222\"]\n    SW[\"Extension service worker\u003cbr/\u003eCDP target / JS context\"]\n    Page[\"Page target\"]\n    CDP -. \"can dispatch to target\" .-\u003e Page\n  end\n\n  Socket[\"CDP socket\"]\n\n  WS \u003c--\u003e|\"2. CDP Browser.getVersion\u003cbr/\u003e5. response\"| Socket\n  Socket \u003c--\u003e|\"3. Standard CDP request\u003cbr/\u003e4. Standard CDP response\"| CDP\n\n  classDef idle fill:#f7f7f7,stroke:#bbb,color:#777;\n  class SW,Page idle;\n```\n\n#### 2. Normal CDP Event Listener / Event\n\n```mermaid\nflowchart LR\n  subgraph Node[\"Node client\"]\n    direction LR\n    SDK[\"SDK\"]\n    WS[\"WS client\"]\n    SDK --\u003e|\"1. cdp.on('Target.targetCreated', ...)\"| WS\n    SDK --\u003e|\"2. cdp.Target.createTarget({url})\"| WS\n  end\n\n  subgraph Browser[\"Browser\"]\n    direction LR\n    CDP[\"CDP router\u003cbr/\u003elocalhost:9222\"]\n    SW[\"Extension service worker\u003cbr/\u003eCDP target / JS context\"]\n    Page[\"Page target\u003cbr/\u003echrome://newtab/\"]\n    CDP --\u003e|\"5. dispatch to page target\"| Page\n  end\n\n  Socket[\"CDP socket\"]\n\n  WS --\u003e|\"3. CDP Target.createTarget\"| Socket\n  Socket --\u003e|\"4. Standard CDP\"| CDP\n  CDP --\u003e|\"6. create page target\"| Page\n  Page --\u003e|\"7. Target.targetCreated\u003cbr/\u003e{targetInfo}\"| CDP\n  CDP --\u003e|\"8. Target.targetCreated\u003cbr/\u003e{targetInfo}\"| Socket\n  Socket --\u003e|\"9. Target.targetCreated\u003cbr/\u003e{targetInfo}\"| WS\n  WS --\u003e|\"10. emit('Target.targetCreated', {targetInfo})\"| SDK\n\n  classDef idle fill:#f7f7f7,stroke:#bbb,color:#777;\n  class SW idle;\n```\n\n#### 3. ModCDP Custom Call / Response\n\n```mermaid\nflowchart LR\n  subgraph Node[\"Node client\"]\n    direction LR\n    SDK[\"SDK\"]\n    WS[\"WS client\"]\n    SDK --\u003e|\"1. cdp.send('Mod.evaluate', ...)\"| WS\n  end\n\n  subgraph Browser[\"Browser\"]\n    direction LR\n    ClientCDP[\"CDP Session for client\u003cbr/\u003elocalhost:9222\"]\n    LoopbackCDP[\"CDP Session for loopback\u003cbr/\u003elocalhost:9222\"]\n    SW[\"Extension service worker\u003cbr/\u003eCDP target / JS context\u003cbr/\u003eglobalThis.ModCDP\"]\n    Page[\"Page target\"]\n    ClientCDP --\u003e|\"4. dispatch Runtime.evaluate(Mod.evaluate)\"| SW\n    LoopbackCDP --\u003e|\"7. Input.dispatchMouseEvent\"| Page\n    Page --\u003e|\"8. Input.dispatchMouseEvent result\"| LoopbackCDP\n    SW -. \"\u003cs\u003echrome.debugger\u003c/s\u003e\u003cbr/\u003enot used\" .-\u003e Page\n  end\n\n  ClientSocket[\"client CDP socket.\u003cbr/\u003ecarries Mod.evaluate ...\"]\n  LoopbackSocket[\"loopback CDP socket.\u003cbr/\u003ecarries standard CDP only\"]\n\n  ClientSocket ~~~ LoopbackSocket\n  WS --\u003e|\"2. Runtime.evaluate(Mod.evaluate)\"| ClientSocket\n  ClientSocket --\u003e|\"3. Runtime.evaluate(Mod.evaluate)\"| ClientCDP\n  SW --\u003e|\"5. WebSocket CDP loopback\u003cbr/\u003eout of Browser\u003cbr/\u003eInput.dispatchMouseEvent\"| LoopbackSocket\n  LoopbackSocket --\u003e|\"6. Input.dispatchMouseEvent\"| LoopbackCDP\n  LoopbackCDP --\u003e|\"9. Input.dispatchMouseEvent result\"| LoopbackSocket\n  LoopbackSocket --\u003e|\"10. Input.dispatchMouseEvent result\u003cbr/\u003eback into Browser\"| SW\n  SW --\u003e|\"11. Runtime.evaluate(Mod.evaluate) result\"| ClientCDP\n  ClientCDP --\u003e|\"12. Runtime.evaluate(Mod.evaluate) result\"| ClientSocket\n  ClientSocket --\u003e|\"13. =\u003e {ok, action, target}\"| WS\n```\n\nThe same transport shape applies to `Mod.addCustomCommand`: the client installs a named command handler in the service worker, and later `cdp.send('Custom.someCommand', params)` is routed back through `globalThis.ModCDP.handleCommand(...)`.\n\n#### 4. ModCDP Custom Event Listener / Event\n\n```mermaid\nflowchart LR\n  subgraph Node[\"Node client\"]\n    direction LR\n    SDK[\"SDK\"]\n    WS[\"WS client\"]\n    SDK --\u003e|\"1. cdp.on('Custom.demo', ...)\"| WS\n    SDK --\u003e|\"6. cdp.send('Mod.evaluate', ...)\"| WS\n  end\n\n  subgraph Browser[\"Browser\"]\n    direction LR\n    ClientCDP[\"CDP Session for client\u003cbr/\u003elocalhost:9222\"]\n    LoopbackCDP[\"CDP Session for loopback\u003cbr/\u003elocalhost:9222\"]\n    SW[\"Extension service worker\u003cbr/\u003eCDP target / JS context\u003cbr/\u003eModCDP + bindings\"]\n    Page[\"Page target\"]\n    ClientCDP --\u003e|\"5. dispatch Runtime.evaluate(Mod.addCustomEvent)\u003cbr/\u003e9. dispatch Runtime.evaluate(Mod.evaluate)\"| SW\n    LoopbackCDP --\u003e|\"12. Input.dispatchMouseEvent\"| Page\n    Page --\u003e|\"13. Input.dispatchMouseEvent result\"| LoopbackCDP\n    SW -. \"\u003cs\u003echrome.debugger\u003c/s\u003e\u003cbr/\u003enot used\" .-\u003e Page\n  end\n\n  ClientSocket[\"client CDP socket.\u003cbr/\u003ecarries ModCDP ...\"]\n  LoopbackSocket[\"loopback CDP socket.\u003cbr/\u003ecarries standard CDP only\"]\n\n  ClientSocket ~~~ LoopbackSocket\n  WS --\u003e|\"2. CDP Runtime.addBinding\"| ClientSocket\n  WS --\u003e|\"3. Mod.addCustomEvent\u003cbr/\u003e7. Mod.evaluate(cdp.emit(...))\"| ClientSocket\n  ClientSocket \u003c--\u003e|\"4. Runtime.evaluate(Mod.addCustomEvent)\u003cbr/\u003e8. Runtime.evaluate(Mod.evaluate)\"| ClientCDP\n  SW --\u003e|\"10. WebSocket CDP loopback\u003cbr/\u003eout of Browser\u003cbr/\u003eInput.dispatchMouseEvent\"| LoopbackSocket\n  LoopbackSocket --\u003e|\"11. Input.dispatchMouseEvent\"| LoopbackCDP\n  LoopbackCDP --\u003e|\"14. Input.dispatchMouseEvent result\"| LoopbackSocket\n  LoopbackSocket --\u003e|\"15. Input.dispatchMouseEvent result\u003cbr/\u003eservice worker emits custom event\"| SW\n  SW --\u003e|\"16. Runtime.bindingCalled\u003cbr/\u003e{name:'__ModCDP_custom_event__', payload:'{event:Custom.demo,data:test}'}\"| ClientCDP\n  ClientCDP --\u003e|\"17. Standard CDP event\u003cbr/\u003eRuntime.bindingCalled {name:'__ModCDP_custom_event__', payload:'{event:Custom.demo,data:test}'}\"| ClientSocket\n  ClientSocket --\u003e|\"18. Standard CDP event\u003cbr/\u003eRuntime.bindingCalled {name:'__ModCDP_custom_event__', payload:'{event:Custom.demo,data:test}'}\"| WS\n  WS --\u003e|\"19. emit('Custom.demo', 'test')\"| SDK\n```\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eConstraints \u0026amp; alternatives explored\u003c/b\u003e\u003c/summary\u003e\n\n**Constraints**\n\n- This does not add real CDP methods to Chrome — the wire methods stay `Runtime.evaluate` + `Runtime.bindingCalled`. The `Mod.*` / `Custom.*` namespace is a client + SW convention.\n- Page JS does not see custom commands or event bindings.\n- Stock Google Chrome's `chrome://inspect/#remote-debugging` toggle can expose the current browser at `localhost:9222` without relaunching with `--remote-debugging-port`, `--enable-unsafe-extension-debugging`, or `--remote-allow-origins=*`.\n- If `Extensions.loadUnpacked` is unavailable in the connected browser, load/install the ModCDP extension in that Chrome profile once and reconnect; the injector will use the discovery path.\n\n**Alternatives considered**\n\n- `chrome.debugger` — used as the server-side fallback, but doesn't expose other connected CDP clients or the raw protocol stream.\n- Extension WebSocket → pass the actual `ws://.../devtools/browser/...` CDP endpoint directly; HTTP `/json/*` discovery is only a compatibility fallback for `http://host:port` shorthand.\n- Listening to another CDP client's traffic — separate clients don't see each other's messages.\n- WebMCP — page-visible/tool-oriented, unsuitable when page JS must not detect the control plane.\n- `Extensions.*` storage mailbox — slower and more brittle than the SW target.\n- A separate local CDP proxy process — clean, but unnecessary for the default flow; the proxy here is opt-in (only used when \"upgrading\" a vanilla CDP client).\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003eLatency (local PoC, headless Chromium 141)\u003c/b\u003e\u003c/summary\u003e\n\n```\nlaunchToFirstBrowserGetVersion:      1262.6 ms\nnormalBrowserGetVersionRoundTrip:       0.7 ms\nsmuggledCustomPingRoundTrip:            9.3 ms\nnormalOnSubscribeTriggerEvent:          1.8 ms\nsmuggledCustomOnSubscribeTriggerEvent: 29.6 ms\n```\n\nCustom roundtrip overhead is dominated by `Runtime.evaluate` + the SW's loopback CDP dial, not by wrap/unwrap. Avoid `auto` discovery in latency-sensitive paths if you can pre-configure `loopback_cdp_url` directly.\n\n\u003c/details\u003e\n\n\u003cdetails\u003e\n\u003csummary\u003e\u003cb\u003emacOS Chrome compatibility matrix (tested 2026-05-01)\u003c/b\u003e\u003c/summary\u003e\n\nTested browsers:\n\n- `/Applications/Google Chrome.app` — Google Chrome `148.0.7778.96 beta`\n- `/Applications/Google Chrome Canary.app` — Google Chrome `149.0.7819.0 canary`\n- Playwright Chrome for Testing — `147.0.7727.15`\n\nLatency columns:\n\n- `direct` — ModCDP client to browser raw CDP `Page.getFrameTree` against an attached `chrome://newtab/` page target.\n- `pong` — ModCDP client to browser to extension service worker `Mod.pong` round trip.\n- `loopback` — ModCDP client to browser to extension service worker to loopback CDP to browser `Page.getFrameTree`.\n- `debugger` — ModCDP client to browser to extension service worker to `chrome.debugger.sendCommand` `Page.getFrameTree`.\n\nThe launched-browser rows used an isolated temporary user data dir. The live/default-profile row is separate because it depends on the user enabling Chrome's `chrome://inspect/#remote-debugging` flow and accepting Chrome's connection prompt.\n\n| Browser                           | UI               | Mode         | Works | `chrome.tabs.query` | `chrome.debugger` | `Browser.getVersion` | `Target.getTargets` | Default profile | direct ms | pong ms | loopback ms | debugger ms |\n| --------------------------------- | ---------------- | ------------ | ----- | ------------------- | ----------------- | -------------------- | ------------------- | --------------- | --------: | ------: | ----------: | ----------: |\n| Chrome Beta 148                   | `--headless=new` | `--direct`   | yes   | yes                 | no                | yes                  | yes                 | no              |       4.8 |       3 |           - |           - |\n| Chrome Beta 148                   | `--headless=new` | `--loopback` | yes   | yes                 | no                | yes                  | yes                 | no              |       2.3 |       2 |        13.5 |           - |\n| Chrome Beta 148                   | `--headless=new` | `--debugger` | no    | yes                 | no                | no                   | no                  | no              |       5.3 |       5 |           - |           - |\n| Chrome Beta 148                   | headful          | `--direct`   | yes   | yes                 | no                | yes                  | yes                 | no              |       5.1 |       1 |           - |           - |\n| Chrome Beta 148                   | headful          | `--loopback` | yes   | yes                 | no                | yes                  | yes                 | no              |       2.4 |       1 |        13.5 |           - |\n| Chrome Beta 148                   | headful          | `--debugger` | no    | yes                 | no                | no                   | no                  | no              |       2.2 |       2 |           - |           - |\n| Chrome Canary 149                 | `--headless=new` | `--direct`   | yes   | yes                 | yes               | yes                  | yes                 | no              |       2.2 |       1 |           - |           - |\n| Chrome Canary 149                 | `--headless=new` | `--loopback` | yes   | yes                 | yes               | yes                  | yes                 | no              |       2.6 |       1 |        14.4 |           - |\n| Chrome Canary 149                 | `--headless=new` | `--debugger` | yes   | yes                 | yes               | no                   | no                  | no              |       2.1 |       1 |           - |         1.4 |\n| Chrome Canary 149                 | headful          | `--direct`   | yes   | yes                 | yes               | yes                  | yes                 | no              |       2.4 |       1 |           - |           - |\n| Chrome Canary 149                 | headful          | `--loopback` | yes   | yes                 | yes               | yes                  | yes                 | no              |       2.2 |       1 |        13.5 |           - |\n| Chrome Canary 149                 | headful          | `--debugger` | yes   | yes                 | yes               | no                   | no                  | no              |       2.3 |       0 |           - |         1.2 |\n| Playwright Chrome for Testing 147 | `--headless=new` | `--direct`   | yes   | yes                 | yes\\*             | yes                  | yes                 | no              |       2.3 |       3 |           - |           - |\n| Playwright Chrome for Testing 147 | `--headless=new` | `--loopback` | yes   | yes                 | yes\\*             | yes                  | yes                 | no              |       1.9 |       1 |        13.0 |           - |\n| Playwright Chrome for Testing 147 | `--headless=new` | `--debugger` | yes   | yes                 | yes\\*             | no                   | no                  | no              |       2.6 |       1 |           - |         0.7 |\n| Playwright Chrome for Testing 147 | headful          | `--direct`   | yes   | yes                 | yes\\*             | yes                  | yes                 | no              |       2.0 |       1 |           - |           - |\n| Playwright Chrome for Testing 147 | headful          | `--loopback` | yes   | yes                 | yes\\*             | yes                  | yes                 | no              |       2.4 |       1 |        12.5 |           - |\n| Playwright Chrome for Testing 147 | headful          | `--debugger` | yes   | yes                 | yes\\*             | no                   | no                  | no              |       2.1 |       1 |           - |         1.2 |\n\n`*` Playwright Chrome for Testing exposes `chrome.debugger` when the ModCDP extension is launched with `--load-extension`. With auto-injection only, `--direct` and `--loopback` still work, but `chrome.debugger` is not available in the borrowed/injected service worker.\n\nLive/default-profile status:\n\n| Browser                           | UI               | Mode     | Result                                                                                                 |\n| --------------------------------- | ---------------- | -------- | ------------------------------------------------------------------------------------------------------ |\n| Chrome Beta 148                   | `--headless=new` | `--live` | not applicable                                                                                         |\n| Chrome Beta 148                   | headful          | `--live` | current advertised `DevToolsActivePort` was stale; websocket failed with `ECONNREFUSED 127.0.0.1:9222` |\n| Chrome Canary 149                 | `--headless=new` | `--live` | not applicable                                                                                         |\n| Chrome Canary 149                 | headful          | `--live` | no active live endpoint found                                                                          |\n| Playwright Chrome for Testing 147 | `--headless=new` | `--live` | not applicable                                                                                         |\n| Playwright Chrome for Testing 147 | headful          | `--live` | no active live endpoint found                                                                          |\n\nMinimum viable macOS CLI args:\n\n| Mode                  | Browsers                          | Args                                                                                                                                   |\n| --------------------- | --------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |\n| `--direct` headful    | all three                         | `--remote-debugging-port=\u003cport\u003e --user-data-dir=\u003ctemp-profile\u003e chrome://newtab/`                                                       |\n| `--direct` headless   | all three                         | `--headless=new --remote-debugging-port=\u003cport\u003e --user-data-dir=\u003ctemp-profile\u003e chrome://newtab/`                                        |\n| `--loopback` headful  | all three                         | `--remote-debugging-port=\u003cport\u003e --user-data-dir=\u003ctemp-profile\u003e --remote-allow-origins=* chrome://newtab/`                              |\n| `--loopback` headless | all three                         | `--headless=new --remote-debugging-port=\u003cport\u003e --user-data-dir=\u003ctemp-profile\u003e --remote-allow-origins=* chrome://newtab/`               |\n| `--debugger`          | Chrome Beta 148                   | no working set found; `chrome.debugger` is unavailable in the extension service worker                                                 |\n| `--debugger` headful  | Chrome Canary 149                 | `--remote-debugging-port=\u003cport\u003e --user-data-dir=\u003ctemp-profile\u003e chrome://newtab/`                                                       |\n| `--debugger` headless | Chrome Canary 149                 | `--headless=new --remote-debugging-port=\u003cport\u003e --user-data-dir=\u003ctemp-profile\u003e chrome://newtab/`                                        |\n| `--debugger` headful  | Playwright Chrome for Testing 147 | `--remote-debugging-port=\u003cport\u003e --user-data-dir=\u003ctemp-profile\u003e --load-extension=\u003crepo\u003e/dist/extension chrome://newtab/`                |\n| `--debugger` headless | Playwright Chrome for Testing 147 | `--headless=new --remote-debugging-port=\u003cport\u003e --user-data-dir=\u003ctemp-profile\u003e --load-extension=\u003crepo\u003e/dist/extension chrome://newtab/` |\n\nRecommended full macOS launch args:\n\n```bash\n--remote-debugging-port=\u003cport\u003e\n--user-data-dir=\u003ctemp-profile\u003e\n--remote-allow-origins=*\n--enable-unsafe-extension-debugging\n--load-extension=\u003crepo\u003e/dist/extension\n--no-first-run\n--no-default-browser-check\n--disable-default-apps\n--disable-background-networking\n--disable-backgrounding-occluded-windows\n--disable-renderer-backgrounding\n--disable-background-timer-throttling\n--disable-sync\n--password-store=basic\n--use-mock-keychain\nchrome://newtab/\n```\n\nAdd `--headless=new` for headless launches. Do not pass `--no-sandbox`, `--disable-gpu`, or `--remote-debugging-address` on macOS. On Linux only, pass `--no-sandbox` when there is no usable sandbox/display environment.\n\n\u003c/details\u003e\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpirate%2Fmodcdp","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Fpirate%2Fmodcdp","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Fpirate%2Fmodcdp/lists"}