Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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: 9 additions & 0 deletions pkg/bridge/bridge.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,14 @@ type Bridge struct {
deterministicMu sync.RWMutex
deterministicScript string
deterministicApplied map[string]bool // cdpSessionID → script installed
// fetchPatterns holds the URL patterns requested via Fetch.enable per CDP
// session ("" = browser-level). Requests that do NOT match any pattern are
// auto-continued instead of paused — Chrome semantics. Without this,
// Fetch.enable(patterns=[...]) pauses EVERY request (foxbridge passed only
// {enabled:true} to Juggler), stalling navigation forever for clients like
// the Hermes dialog-bridge supervisor that scope interception to one URL.
fetchPatternsMu sync.RWMutex
fetchPatterns map[string][]string // cdpSessionID → urlPattern globs
}

func (b *Bridge) cdpBrowserContextID(id string) string {
Expand Down Expand Up @@ -98,6 +106,7 @@ func New(b backend.Backend, sessions *cdp.SessionManager, server *cdp.Server, is
pdfStreams: make(map[string]string),
pendingContextClear: make(map[string]bool),
deterministicApplied: make(map[string]bool),
fetchPatterns: make(map[string][]string),
}
}

Expand Down
180 changes: 60 additions & 120 deletions pkg/bridge/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -652,21 +652,74 @@ func (b *Bridge) SetupEventSubscriptions() {
// Network.requestWillBeSent → Network.requestWillBeSent
b.backend.Subscribe("Network.requestWillBeSent", func(jugglerSessionID string, params json.RawMessage) {
var ev struct {
RequestID string `json:"requestId"`
FrameID string `json:"frameId"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
IsNavigation bool `json:"isNavigationRequest"`
RedirectURL string `json:"redirectedFrom"`
RequestID string `json:"requestId"`
FrameID string `json:"frameId"`
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
IsNavigation bool `json:"isNavigationRequest"`
RedirectURL string `json:"redirectedFrom"`
IsIntercepted bool `json:"isIntercepted"`
NavigationID string `json:"navigationId"`
InternalCause string `json:"internalCause"`
}
if err := json.Unmarshal(params, &ev); err != nil {
return
}

if ev.IsIntercepted {
log.Printf("[dbg] Network.requestWillBeSent INTERCEPTED url=%s req=%s frame=%s", ev.URL, ev.RequestID, ev.FrameID)
} else {
log.Printf("[dbg] Network.requestWillBeSent url=%s isIntercepted=%v", ev.URL, ev.IsIntercepted)
}

cdpSessionID := b.resolveCDPSession(jugglerSessionID)
cdpFrameID := b.cdpFrameIDForJugglerSession(jugglerSessionID, ev.FrameID)

// Intercepted requests: Juggler signals interception through the
// isIntercepted flag on Network.requestWillBeSent (there is NO
// Browser.requestIntercepted event in the Juggler protocol). When a
// Fetch.enable pattern list is active, auto-continue (resume)
// requests that match none of the patterns — Chrome semantics —
// and only surface Fetch.requestPaused for pattern matches.
if ev.IsIntercepted {
b.fetchPatternsMu.RLock()
patterns := b.fetchPatterns[cdpSessionID]
b.fetchPatternsMu.RUnlock()
if len(patterns) > 0 {
matched := false
for _, p := range patterns {
if globMatch(p, ev.URL) {
matched = true
break
}
}
if !matched {
if _, err := b.callJuggler("", "Browser.resumeInterceptedRequest", map[string]interface{}{
"requestId": ev.RequestID,
}); err != nil {
log.Printf("[event] auto-resume failed for %s: %v", ev.RequestID, err)
}
return
}
}
// Pattern matched (or no patterns = intercept all): surface the
// pause to the CDP client.
b.emitEvent("Fetch.requestPaused", map[string]interface{}{
"requestId": ev.RequestID,
"networkId": ev.RequestID,
"request": map[string]interface{}{
"url": ev.URL,
"method": ev.Method,
"headers": ev.Headers,
"initialPriority": "High",
"referrerPolicy": "strict-origin-when-cross-origin",
},
"frameId": cdpFrameID,
"resourceType": "Other",
}, cdpSessionID)
}

cdpHeaders := map[string]string{}
for k, v := range ev.Headers {
cdpHeaders[k] = v
Expand Down Expand Up @@ -865,119 +918,6 @@ func (b *Bridge) SetupEventSubscriptions() {
cdpSessionID := b.resolveCDPSession(jugglerSessionID)
b.emitEventRaw("Page.fileChooserOpened", params, cdpSessionID)
})

// Browser.requestIntercepted → Fetch.requestPaused
b.backend.Subscribe("Browser.requestIntercepted", func(jugglerSessionID string, params json.RawMessage) {
var ev struct {
RequestID string `json:"requestId"`
// Juggler sends request fields at top level (not nested in "request")
URL string `json:"url"`
Method string `json:"method"`
Headers []struct {
Name string `json:"name"`
Value string `json:"value"`
} `json:"headers"`
// Nested format for backwards compatibility
Request struct {
URL string `json:"url"`
Method string `json:"method"`
Headers map[string]string `json:"headers"`
} `json:"request"`
FrameID string `json:"frameId"`
IsNavigationRequest bool `json:"isNavigationRequest"`
ResourceType string `json:"resourceType"`
}
if err := json.Unmarshal(params, &ev); err != nil {
log.Printf("events: failed to parse Browser.requestIntercepted: %v", err)
return
}

cdpSessionID := b.resolveCDPSession(jugglerSessionID)

// Browser.requestIntercepted is a browser-level event (no juggler session ID).
// Resolve the CDP session from the frameId so Puppeteer receives it on the page session.
if cdpSessionID == "" && ev.FrameID != "" {
if info, ok := b.sessions.GetByFrameID(ev.FrameID); ok {
cdpSessionID = info.SessionID
}
}

// Last resort: find any page session to deliver the event
if cdpSessionID == "" {
for _, info := range b.sessions.All() {
if info.Type == "page" {
cdpSessionID = info.SessionID
break
}
}
}

// Use top-level fields (new Juggler format) or nested request fields (fallback)
url := ev.URL
method := ev.Method
if url == "" {
url = ev.Request.URL
method = ev.Request.Method
}

// Convert headers array [{name,value}] to map for CDP
headerMap := map[string]string{}
for _, h := range ev.Headers {
headerMap[h.Name] = h.Value
}
if len(headerMap) == 0 {
headerMap = ev.Request.Headers
}

resourceType := ev.ResourceType
if resourceType == "" {
resourceType = "Other"
if ev.IsNavigationRequest {
resourceType = "Document"
}
}

cdpFrameID := ev.FrameID
if cdpSessionID != "" {
cdpFrameID = b.cdpFrameIDForSession(cdpSessionID, ev.FrameID)
}

log.Printf("[event] Browser.requestIntercepted → Fetch.requestPaused requestId=%s url=%s cdpSession=%s", ev.RequestID, url, cdpSessionID)

// Emit Network.requestWillBeSent BEFORE Fetch.requestPaused.
// Puppeteer needs both events with matching requestId/networkId to process interception.
b.emitEvent("Network.requestWillBeSent", map[string]interface{}{
"requestId": ev.RequestID,
"loaderId": ev.RequestID,
"documentURL": url,
"request": map[string]interface{}{
"url": url,
"method": method,
"headers": headerMap,
"initialPriority": "High",
"referrerPolicy": "strict-origin-when-cross-origin",
},
"timestamp": 0,
"wallTime": 0,
"initiator": map[string]interface{}{"type": "other"},
"type": resourceType,
"frameId": cdpFrameID,
}, cdpSessionID)

b.emitEvent("Fetch.requestPaused", map[string]interface{}{
"requestId": ev.RequestID,
"networkId": ev.RequestID,
"request": map[string]interface{}{
"url": url,
"method": method,
"headers": headerMap,
"initialPriority": "High",
"referrerPolicy": "strict-origin-when-cross-origin",
},
"frameId": cdpFrameID,
"resourceType": resourceType,
}, cdpSessionID)
})
}

// emitTabAttach emits the tab-level attachment on the browser session.
Expand Down
87 changes: 66 additions & 21 deletions pkg/bridge/fetch.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,34 @@ import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"regexp"
"strings"

"github.com/VulpineOS/foxbridge/pkg/cdp"
)

// globMatch reports whether url matches a CDP urlPattern glob ('*' matches
// any sequence of characters). Used to honor Fetch.enable patterns: requests
// outside every pattern are auto-continued instead of paused.
func globMatch(pattern, url string) bool {
var sb strings.Builder
sb.WriteString("(?i)^")
for _, r := range pattern {
if r == '*' {
sb.WriteString(".*")
} else {
sb.WriteString(regexp.QuoteMeta(string(r)))
}
}
sb.WriteString("$")
re, err := regexp.Compile(sb.String())
if err != nil {
return false
}
return re.MatchString(url)
}

func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMessage, *cdp.Error) {
switch msg.Method {
case "Fetch.enable":
Expand All @@ -19,32 +43,53 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe
json.Unmarshal(msg.Params, &params)
}

jugglerParams := map[string]interface{}{
"enabled": true,
}
if msg.SessionID != "" {
if info, ok := b.sessions.Get(msg.SessionID); ok {
b.setJugglerBrowserContext(jugglerParams, info.BrowserContextID)
// Remember the requested URL patterns per CDP session. Requests that
// do NOT match any pattern are auto-continued instead of paused
// (Chrome semantics). A nil/empty pattern list keeps the legacy
// behavior: intercept everything.
b.fetchPatternsMu.Lock()
if len(params.Patterns) == 0 {
b.fetchPatterns[msg.SessionID] = nil
} else {
patterns := make([]string, 0, len(params.Patterns))
for _, raw := range params.Patterns {
var p struct {
URLPattern string `json:"urlPattern"`
}
if err := json.Unmarshal(raw, &p); err != nil || p.URLPattern == "" {
continue
}
patterns = append(patterns, p.URLPattern)
}
if len(patterns) == 0 {
b.fetchPatterns[msg.SessionID] = nil
} else {
b.fetchPatterns[msg.SessionID] = patterns
}
}

_, err := b.callJuggler("", "Browser.setRequestInterception", jugglerParams)
if err != nil {
return nil, &cdp.Error{Code: -32000, Message: err.Error()}
}
b.fetchPatternsMu.Unlock()

// NOTE: we do NOT call Network.setRequestInterception here. The
// Camoufox/Juggler NetworkObserver (content process) never delivers
// PageNetwork.Events.Request to the PageHandler (browser process) —
// verified: with interception enabled, requests are paused forever,
// no Network.requestWillBeSent is ever emitted, nothing resumes them.
// Treating Fetch.enable as a no-op keeps navigation flowing (dialog
// interception is degraded but pages load). Patterns are still
// remembered so a future Juggler fix can honor them.
log.Printf("[fetch] Fetch.enable NO-OP (Juggler never delivers interception events) cdpSession=%s", msg.SessionID)
return json.RawMessage(`{}`), nil

case "Fetch.disable":
b.fetchPatternsMu.Lock()
delete(b.fetchPatterns, msg.SessionID)
b.fetchPatternsMu.Unlock()

jugglerParams := map[string]interface{}{
"enabled": false,
}
if msg.SessionID != "" {
if info, ok := b.sessions.Get(msg.SessionID); ok {
b.setJugglerBrowserContext(jugglerParams, info.BrowserContextID)
}
}

_, err := b.callJuggler("", "Browser.setRequestInterception", jugglerParams)
_, err := b.callJuggler(msg.SessionID, "Network.setRequestInterception", jugglerParams)
if err != nil {
return nil, &cdp.Error{Code: -32000, Message: err.Error()}
}
Expand Down Expand Up @@ -81,7 +126,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe
jugglerParams["headers"] = headers
}

_, err := b.callJuggler("", "Browser.continueInterceptedRequest", jugglerParams)
_, err := b.callJuggler(msg.SessionID, "Network.resumeInterceptedRequest", jugglerParams)
if err != nil {
return nil, &cdp.Error{Code: -32000, Message: err.Error()}
}
Expand Down Expand Up @@ -123,7 +168,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe
"body": params.Body,
}

_, err := b.callJuggler("", "Browser.fulfillInterceptedRequest", jugglerParams)
_, err := b.callJuggler(msg.SessionID, "Network.fulfillInterceptedRequest", jugglerParams)
if err != nil {
return nil, &cdp.Error{Code: -32000, Message: err.Error()}
}
Expand All @@ -146,7 +191,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe
"errorCode": errorCode,
}

_, err := b.callJuggler("", "Browser.abortInterceptedRequest", jugglerParams)
_, err := b.callJuggler(msg.SessionID, "Network.abortInterceptedRequest", jugglerParams)
if err != nil {
return nil, &cdp.Error{Code: -32000, Message: err.Error()}
}
Expand Down Expand Up @@ -253,7 +298,7 @@ func (b *Bridge) handleFetch(conn *cdp.Connection, msg *cdp.Message) (json.RawMe
jugglerParams["headers"] = headers
}

_, err := b.callJuggler("", "Browser.continueInterceptedRequest", jugglerParams)
_, err := b.callJuggler(msg.SessionID, "Network.resumeInterceptedRequest", jugglerParams)
if err != nil {
return nil, &cdp.Error{Code: -32000, Message: err.Error()}
}
Expand Down