Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
18bbbb3
Validate ModCDP protocol surfaces
pirate May 15, 2026
44c7a66
Fix ModCDP CI checks
pirate May 15, 2026
4539f17
Fix borrowed injector bootstrap lookup
pirate May 15, 2026
4cb1ef9
Allow demos more Chrome startup time
pirate May 15, 2026
463ee5a
Allow proxy examples more Chrome startup time
pirate May 15, 2026
fca3051
Stabilize ModCDPClient browser tests
pirate May 15, 2026
c4ec952
Align Python ModCDPClient browser tests
pirate May 15, 2026
c40259a
Use Python auto injector path in browser test
pirate May 15, 2026
06becd2
Fix Python pipe browser launch
pirate May 15, 2026
313f12b
Pass Python pipe fds directly
pirate May 15, 2026
6008edb
Stabilize Python pipe fd setup
pirate May 15, 2026
b810472
Align remote close browser tests
pirate May 16, 2026
2dfa879
Stabilize reverse transport CI paths
pirate May 16, 2026
943f269
Give router live context tests CI time
pirate May 16, 2026
b3f853b
Avoid parent fd mutation for Python pipe launch
pirate May 16, 2026
8cd3d8f
Use posix spawn for Python pipe launch
pirate May 16, 2026
e07e4dd
Allow remote close test full browser budget
pirate May 16, 2026
67b466d
Set remote close test timeout
pirate May 16, 2026
566e1f0
Correct remote close timeout placement
pirate May 16, 2026
bd87af3
Use injector path in remote close tests
pirate May 16, 2026
bfe7ea9
Preload extension for remote close tests
pirate May 16, 2026
583fa3c
Use reversews browser helper in Python client test
pirate May 16, 2026
2326b88
Use reversews browser helper in Go client test
pirate May 16, 2026
965fe7f
Bump ModCDP versions
pirate May 16, 2026
a8c8fe9
Fix launcher sandbox defaults
pirate May 16, 2026
77f930d
Stabilize Python pipe fd mapping
pirate May 16, 2026
5ad98ca
Map Python pipe fds in child process
pirate May 16, 2026
1765b4f
Pass custom command params as runtime arguments
pirate May 16, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -150,12 +150,13 @@ Native messaging mode creates the local native host wrapper and browser manifest
Use 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:

```sh
pnpm run proxy -- --upstream-mode=reversews --upstream-reversews-bind=127.0.0.1:29292 --listen 127.0.0.1:9223
pnpm run proxy -- --launcher-mode=local --upstream-mode=reversews --upstream-reversews-bind=127.0.0.1:29292 --port 9223
pnpm run proxy -- --upstream-mode=reversews --port 9223
pnpm run proxy -- --launcher-mode=local --upstream-mode=reversews --port 9223
pnpm run proxy -- --launcher-mode=local --upstream-mode=reversews --upstream-reversews-bind=127.0.0.1:29293 --port 9223
# const browser = await playwright.chromium.connectOverCDP("http://127.0.0.1:9223")
```

Reverse mode is opt-in. The shipped extension has no generated runtime config; it always tries the fixed local reverse connector at `ws://127.0.0.1:29292`. With `--launcher-mode=local`, use that default bind and the normal `ModCDPClient` launcher, injector, and `ReverseWebSocketUpstreamTransport` path will wake the service worker and accept its connection. With `--launcher-mode=none` or a non-default bind, an already-running extension or test must explicitly call `globalThis.ModCDP.startReverseBridge("ws://host:port", { reconnect_interval_ms: 2000 })` with the chosen endpoint because there is no side channel before the reverse connection exists. `--upstream-reversews-wait-timeout-ms` controls how long the proxy/client waits for that extension connection. Once it connects, it self-identifies as a ModCDP extension 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.
Reverse 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.

Reverse 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`.

Expand Down Expand Up @@ -230,7 +231,7 @@ dist/ Built JS output used by the extension and Node CLI scr
3. Attach a session to that SW target and `Runtime.enable` on it.
4. 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.

Reverse proxy mode flips the bootstrap direction: `js/src/proxy/proxy.ts --upstream-mode=reversews --upstream-reversews-bind=127.0.0.1:29292` listens for the extension, while still serving downstream clients from `--listen`. The extension artifact is fixed, so automatic launch/injection uses the default `127.0.0.1:29292` connector; non-default reverse binds need an external bootstrap call to `globalThis.ModCDP.startReverseBridge(...)` with the same URL. 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.
Reverse 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.

### Send

Expand Down
2 changes: 0 additions & 2 deletions TODO_layout.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,6 @@ repo/
pages/
options.html
options.ts
wake.html
wake.ts
offscreen_keepalive.html
offscreen_keepalive.ts

Expand Down
2 changes: 1 addition & 1 deletion extension/src/pages/options.html
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,4 @@
<button id="refresh">Refresh</button>
<h1>ModCDP</h1>
<pre id="out">Loading...</pre>
<script src="options.js"></script>
<script type="module" src="options.js"></script>
10 changes: 0 additions & 10 deletions extension/src/pages/wake.html

This file was deleted.

3 changes: 0 additions & 3 deletions extension/src/pages/wake.ts

This file was deleted.

9 changes: 0 additions & 9 deletions extension/src/service_worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -190,15 +190,6 @@ chrome.runtime.onInstalled.addListener(startConfiguredTransports);
chrome.runtime.onStartup.addListener(startConfiguredTransports);

chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
if (message?.type === "modcdp.wake") {
startConfiguredTransports();
sendResponse({
ok: true,
extension_id: chrome.runtime.id,
service_worker_url: chrome.runtime.getURL("modcdp/service_worker.js"),
});
return false;
}
if (message?.type !== "modcdp.options.status") return false;
const self = {
id: "self",
Expand Down
23 changes: 18 additions & 5 deletions go/examples/demo/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,23 @@ import (
"golang.org/x/term"
)

const reverseTransportWaitTimeoutMS = 60_000

func optionsFor(mode, upstreamMode, cdpURL, extensionPath string, launchOptions modcdp.LaunchOptions) modcdp.Options {
upstream := modcdp.UpstreamConfig{UpstreamMode: upstreamMode, UpstreamCDPURL: cdpURL}
if upstreamMode == "reversews" {
upstream.UpstreamReverseWSWaitTimeoutMS = reverseTransportWaitTimeoutMS
}
if upstreamMode == "nativemessaging" {
upstream.UpstreamNativeMessagingWaitTimeoutMS = reverseTransportWaitTimeoutMS
}
if upstreamMode == "nats" {
upstream.UpstreamNATSWaitTimeoutMS = reverseTransportWaitTimeoutMS
}
if mode == "direct" {
return modcdp.Options{
Launcher: modcdp.LauncherConfig{LauncherMode: map[bool]string{true: "remote", false: "local"}[cdpURL != ""], LauncherOptions: launchOptions},
Upstream: modcdp.UpstreamConfig{UpstreamMode: upstreamMode, UpstreamCDPURL: cdpURL},
Upstream: upstream,
Injector: modcdp.InjectorConfig{InjectorMode: "auto", InjectorExtensionPath: extensionPath},
Client: modcdp.ClientConfig{ClientRoutes: clientRoutesFor(mode)},
}
Expand All @@ -43,7 +55,7 @@ func optionsFor(mode, upstreamMode, cdpURL, extensionPath string, launchOptions
}
return modcdp.Options{
Launcher: modcdp.LauncherConfig{LauncherMode: map[bool]string{true: "remote", false: "local"}[cdpURL != ""], LauncherOptions: launchOptions},
Upstream: modcdp.UpstreamConfig{UpstreamMode: upstreamMode, UpstreamCDPURL: cdpURL},
Upstream: upstream,
Injector: modcdp.InjectorConfig{InjectorMode: "auto", InjectorExtensionPath: extensionPath},
Client: modcdp.ClientConfig{ClientRoutes: clientRoutesFor(mode)},
Server: server,
Expand Down Expand Up @@ -199,9 +211,10 @@ func main() {
headless = true
}
launchOptions = modcdp.LaunchOptions{
ExecutablePath: chromePath,
Headless: &headless,
Sandbox: &sandbox,
ExecutablePath: chromePath,
ChromeReadyTimeoutMS: 60_000,
Headless: &headless,
Sandbox: &sandbox,
}
}

Expand Down
2 changes: 1 addition & 1 deletion go/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/browserbase/modcdp/go
go 1.25.0

require (
github.com/ArchiveBox/abxbus/abxbus-go v0.0.0-20260508073455-c5b79a4483f1
github.com/ArchiveBox/abxbus/abxbus-go/v2 v2.5.2
github.com/gobwas/ws v1.4.0
golang.org/x/term v0.6.0
)
Expand Down
4 changes: 2 additions & 2 deletions go/go.sum
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
github.com/ArchiveBox/abxbus/abxbus-go v0.0.0-20260508073455-c5b79a4483f1 h1:AMZ2Zr6mfP9aDuKjcJvxHkZvlqqf6jQhyk5Ju3jyfY8=
github.com/ArchiveBox/abxbus/abxbus-go v0.0.0-20260508073455-c5b79a4483f1/go.mod h1:yYB8LZqGpYryBo38Hl2s3dhPZweuUwqQxiMZGrY8XXE=
github.com/ArchiveBox/abxbus/abxbus-go/v2 v2.5.2 h1:qpCQCuZ/Nfi6mHf00Ed9nI88JBaAMOh+PhXEO1hdcfQ=
github.com/ArchiveBox/abxbus/abxbus-go/v2 v2.5.2/go.mod h1:Nk7W3WwqKgQw07gQ6V+UdaUJzJwa0Qj78F/n7sERs6A=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
Expand Down
51 changes: 37 additions & 14 deletions go/modcdp/client/ModCDPClient.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,14 +23,13 @@ import (
"net"
"net/http"
"net/url"
"os"
"reflect"
"regexp"
"strings"
"sync"
"time"

abxjsonschema "github.com/ArchiveBox/abxbus/abxbus-go/jsonschema"
abxjsonschema "github.com/ArchiveBox/abxbus/abxbus-go/v2/jsonschema"
"github.com/browserbase/modcdp/go/modcdp/injector"
"github.com/browserbase/modcdp/go/modcdp/launcher"
"github.com/browserbase/modcdp/go/modcdp/router"
Expand Down Expand Up @@ -110,7 +109,6 @@ var NewAutoSessionRouter = router.NewAutoSessionRouter

var DefaultModCDPServiceWorkerURLSuffixes = injector.DefaultModCDPServiceWorkerURLSuffixes

const DefaultModCDPWakePath = injector.DefaultModCDPWakePath
const DefaultModCDPExtensionID = injector.DefaultModCDPExtensionID
const DefaultUpstreamReverseWSBind = transportpkg.DefaultUpstreamReverseWSBind
const DefaultUpstreamReverseWSWaitTimeoutMS = transportpkg.DefaultUpstreamReverseWSWaitTimeoutMS
Expand Down Expand Up @@ -238,8 +236,6 @@ type InjectorConfig struct {
InjectorMode string `json:"injector_mode,omitempty"`
InjectorExtensionPath string `json:"injector_extension_path,omitempty"`
InjectorExtensionID string `json:"injector_extension_id,omitempty"`
InjectorWakePath string `json:"injector_wake_path,omitempty"`
InjectorWakeURL string `json:"injector_wake_url,omitempty"`
InjectorServiceWorkerURLIncludes []string `json:"injector_service_worker_url_includes,omitempty"`
InjectorServiceWorkerURLSuffixes []string `json:"injector_service_worker_url_suffixes,omitempty"`
InjectorTrustServiceWorkerTarget bool `json:"injector_trust_service_worker_target,omitempty"`
Expand Down Expand Up @@ -563,6 +559,7 @@ func New(opts Options) *ModCDPClient {
if *client.Client.ClientHydrateAliases {
initCDPSurface(client)
}
client.hydrateNativeProtocolSchemas()
client.hydrateCustomSurface()
return client
}
Expand Down Expand Up @@ -929,11 +926,6 @@ func (c *ModCDPClient) serverConfigureParams(customCommands []map[string]any, cu
if c.Upstream.UpstreamNATSSubjectPrefix != "" {
upstream["upstream_nats_subject_prefix"] = c.Upstream.UpstreamNATSSubjectPrefix
}
if c.transport != nil {
if reverseWSURL := c.transport.GetInjectorConfig().UpstreamReverseWSURL; reverseWSURL != "" {
upstream["upstream_reversews_url"] = reverseWSURL
}
}
return map[string]any{
"upstream": upstream,
"client": map[string]any{
Expand Down Expand Up @@ -972,6 +964,40 @@ func cloneSchema(schema map[string]any) map[string]any {
return normalized
}

func nativeResultSchema(schema map[string]any) map[string]any {
normalized := cloneSchema(schema)
allowNativeResultExtensions(normalized)
return normalized
}

func allowNativeResultExtensions(schema map[string]any) {
if schema == nil {
return
}
if schemaType, _ := schema["type"].(string); schemaType == "object" {
schema["additionalProperties"] = true
if properties, ok := schema["properties"].(map[string]any); ok {
for _, property := range properties {
if propertySchema, ok := property.(map[string]any); ok {
allowNativeResultExtensions(propertySchema)
}
}
}
}
if items, ok := schema["items"].(map[string]any); ok {
allowNativeResultExtensions(items)
}
for _, key := range []string{"anyOf", "oneOf", "allOf"} {
if schemas, ok := schema[key].([]any); ok {
for _, entry := range schemas {
if entrySchema, ok := entry.(map[string]any); ok {
allowNativeResultExtensions(entrySchema)
}
}
}
}
}

func resultUnwrapKeyFromSchema(schema map[string]any) string {
properties, _ := schema["properties"].(map[string]any)
if len(properties) != 1 {
Expand Down Expand Up @@ -1146,8 +1172,7 @@ func (c *ModCDPClient) validateEventData(event string, data any) (any, bool) {
return data, true
}
if err := abxjsonschema.Validate(schema, data); err != nil {
fmt.Fprintf(os.Stderr, "[ModCDPClient] %s event did not match event_schema: %v\n", event, err)
return nil, false
panic(fmt.Errorf("%s event did not match event_schema: %w", event, err))
}
return data, true
}
Expand Down Expand Up @@ -1497,8 +1522,6 @@ func (c *ModCDPClient) baseExtensionInjectorConfig(send SendCDP) ExtensionInject
},
InjectorExtensionPath: c.Injector.InjectorExtensionPath,
InjectorExtensionID: c.Injector.InjectorExtensionID,
InjectorWakePath: firstNonEmptyString(c.Injector.InjectorWakePath, DefaultModCDPWakePath),
InjectorWakeURL: c.Injector.InjectorWakeURL,
InjectorServiceWorkerURLIncludes: c.Injector.InjectorServiceWorkerURLIncludes,
InjectorServiceWorkerURLSuffixes: c.Injector.InjectorServiceWorkerURLSuffixes,
InjectorTrustServiceWorkerTarget: trustMatchedServiceWorker,
Expand Down
18 changes: 12 additions & 6 deletions go/modcdp/client/ModCDPClientCustomFlatNamespace_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import (
"testing"
"time"

abxjsonschema "github.com/ArchiveBox/abxbus/abxbus-go/jsonschema"
abxjsonschema "github.com/ArchiveBox/abxbus/abxbus-go/v2/jsonschema"
)

func TestCustomCommandsInstallFlatNamespaceThroughRealServiceWorker(t *testing.T) {
Expand All @@ -24,7 +24,6 @@ func TestCustomCommandsInstallFlatNamespaceThroughRealServiceWorker(t *testing.T
Launcher: LauncherConfig{LauncherMode: "local",
LauncherOptions: LaunchOptions{
Headless: boolPtr(true),
Sandbox: boolPtr(false),
},
},
Upstream: UpstreamConfig{UpstreamMode: "ws"},
Expand Down Expand Up @@ -84,7 +83,6 @@ func TestCustomEventsValidateRawStringHandlersThroughRealServiceWorker(t *testin
Launcher: LauncherConfig{LauncherMode: "local",
LauncherOptions: LaunchOptions{
Headless: boolPtr(true),
Sandbox: boolPtr(false),
},
},
Upstream: UpstreamConfig{UpstreamMode: "ws"},
Expand Down Expand Up @@ -236,7 +234,15 @@ func TestTypedCustomEventRegistrationAndHandler(t *testing.T) {
if got := <-seen; got != "ok" {
t.Fatalf("unexpected typed event data %q", got)
}
if _, ok := cdp.validateEventData("Custom.someEvent", map[string]any{"data": 123}); ok {
t.Fatal("expected typed event schema to reject wrong data type")
}
expectPanic(t, func() { cdp.validateEventData("Custom.someEvent", map[string]any{"data": 123}) })
}

func expectPanic(t *testing.T, fn func()) {
t.Helper()
defer func() {
if recover() == nil {
t.Fatal("expected panic")
}
}()
fn()
}
40 changes: 23 additions & 17 deletions go/modcdp/client/ModCDPClientRoutedDefaultOverrides_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,25 +60,31 @@ async (payload, next) => {

func TestModCDPClientRoutedDefaultOverrides(t *testing.T) {
headless := true
sandbox := false
extensionPath, err := filepath.Abs("../../../dist/extension")
if err != nil {
t.Fatal(err)
}
chrome, err := NewLocalBrowserLauncher(LaunchOptions{
Headless: &headless,
Sandbox: &sandbox,
ExtraArgs: []string{"--load-extension=" + extensionPath},
}).Launch(LaunchOptions{})
if err != nil {
owner := New(Options{
Launcher: LauncherConfig{
LauncherMode: "local",
LauncherOptions: LaunchOptions{Headless: &headless},
},
Upstream: UpstreamConfig{UpstreamMode: "ws"},
Injector: InjectorConfig{
InjectorMode: "auto",
InjectorExtensionPath: extensionPath,
InjectorServiceWorkerURLSuffixes: []string{"/modcdp/service_worker.js"},
InjectorTrustServiceWorkerTarget: true,
},
})
if err := owner.Connect(); err != nil {
t.Fatal(err)
}
cdp := New(Options{
Launcher: LauncherConfig{LauncherMode: "remote"},
Upstream: UpstreamConfig{UpstreamMode: "ws", UpstreamCDPURL: chrome.CDPURL},
Upstream: UpstreamConfig{UpstreamMode: "ws", UpstreamCDPURL: owner.CDPURL},
Injector: InjectorConfig{
InjectorMode: "auto",
InjectorExtensionPath: extensionPath,
InjectorMode: "discover",
InjectorServiceWorkerURLSuffixes: []string{"/modcdp/service_worker.js"},
InjectorTrustServiceWorkerTarget: true,
},
Expand All @@ -90,24 +96,24 @@ func TestModCDPClientRoutedDefaultOverrides(t *testing.T) {
},
},
Server: &ServerConfig{
ServerLoopbackCDPURL: chrome.CDPURL,
ServerLoopbackCDPURL: owner.CDPURL,
ServerRoutes: map[string]string{"*.*": "loopback_cdp"},
},
})
defer chrome.Close()
defer owner.Close()
defer cdp.Close()

if err := cdp.Connect(); err != nil {
t.Fatal(err)
}
if cdp.CDPURL != chrome.CDPURL {
t.Fatalf("CDPURL = %q, expected %q", cdp.CDPURL, chrome.CDPURL)
if cdp.CDPURL != owner.CDPURL {
t.Fatalf("CDPURL = %q, expected %q", cdp.CDPURL, owner.CDPURL)
}
if cdp.Server.ServerLoopbackCDPURL != chrome.CDPURL {
t.Fatalf("ServerLoopbackCDPURL = %q, expected %q", cdp.Server.ServerLoopbackCDPURL, chrome.CDPURL)
if cdp.Server.ServerLoopbackCDPURL != owner.CDPURL {
t.Fatalf("ServerLoopbackCDPURL = %q, expected %q", cdp.Server.ServerLoopbackCDPURL, owner.CDPURL)
}

rawTargets, err := cdp.sendMessage("Target.getTargets", map[string]any{}, "")
rawTargets, err := cdp.Send("Target.getTargets", nil)
if err != nil {
t.Fatal(err)
}
Expand Down
Loading
Loading