Documentation

Network

The network layer is what sits between the page and the open internet: ads load through it, APIs run through it, fonts and tracker pixels and WebSockets all pass through it. WRC gives you four levers on that layer — block, cache, observe, mutate — and they all share the same URL-wildcard vocabulary, so you build muscle memory once and reuse it everywhere.

TL;DR
  • Two persistent controls: SetBlockList (drop matching requests) and SetStaticPaths (serve matching requests from a server-side cache).
  • Two one-shot observers: WaitForAnyRequest and WaitForAnyResponse race a set of URL patterns and resolve on the first hit. Set Abort to drop the request with an empty 200 instead of letting it through.
  • One one-shot mutator: ModifyRequest waits for a single matching request, rewrites headers and/or body, then forwards it.
  • URL patterns are simple wildcards — *.example.com/*, */api/login. No regex.
  • Header builders read like prose: AddHeader("X-Trace", "abc").Before("Cookie"), EditHeader(...), RemoveHeader(...).

The four levers at a glance

LeverLifetimeDirectionUse it for
SetBlockListpersistent (until replaced)requestAds, trackers, analytics, anything you never want loaded.
SetStaticPathspersistent (until replaced)requestReplay frozen JS/CSS/image bundles from a snapshot. Big speed/cost wins on repeated runs.
WaitForAnyRequestone-shotrequestVerify the page actually fires the API call you expect, optionally dropping it before it leaves.
WaitForAnyResponseone-shotresponseCapture an API response body for assertions or to seed downstream logic.
ModifyRequestone-shotrequestAdd auth headers, rewrite a body, swap a User-Agent for one specific call.

Everything beyond this point is just filling those rows in.

URL patterns

Every network method speaks the same wildcard syntax: * matches any character span. There is no regex, no glob brackets, no anchoring — the pattern is matched against the full request URL. A few examples that come up constantly:

  • *.doubleclick.net/* — every request to any doubleclick subdomain
  • *googletagmanager.com* — anywhere in the URL contains that string
  • */api/login — any host, path ends in /api/login
  • https://example.com/v2/* — exact host + path prefix

Patterns are case-insensitive and compared against the URL as the browser sees it (after redirects, with query string included).

Block requests with SetBlockList

SetBlockList swaps the session's current blocklist for a new one. Matching requests never leave the browser — they fail as if the network itself rejected them. Pass an empty slice to clear the list.

_ = browser.SetBlockList(ctx, []string{
  "*.doubleclick.net/*",
  "*googletagmanager.com*",
  "*.hotjar.com/*",
  "*/collect?*",
})

Two things worth burning in:

  • The call replaces the list, it does not append. Track the full list in your code if you want to add patterns dynamically.
  • Blocked requests fail at the network layer. Page JavaScript will see a normal request error (often as a rejected fetch or a console warning) — useful for ad/tracker filtering, occasionally surprising if the site degrades unhappily without that one beacon.

Cache static assets with SetStaticPaths

SetStaticPaths wires the session to a server-side blob cache. On cache hit, matching GETs are answered from the snapshot without touching the origin; on cache miss, the request goes out normally and the response is stored for next time. Same shape: empty patterns disables.

_ = browser.SetStaticPaths(ctx, "snap-2026-05", []string{
  "*.example.com/static/*",
  "*.example.com/*.js",
  "*.example.com/*.css",
})

Worth using when you run the same flow repeatedly against the same origin: the JS/CSS/image bundles don't move between runs, but they re-download every time you spin up a fresh session. Snapshotting them once typically cuts both wall-clock time and outbound bandwidth on repeat runs significantly. The blobName is the key your snapshots live under server-side — choose a stable per-environment name and keep using it.

Observe a single request with WaitForAnyRequest

WaitForAnyRequest blocks until the next request matching any of your patterns is observed, then resolves with which pattern matched (by index) and the request that triggered it. It is one-shot: the next match wins, subsequent matching requests are not intercepted.

The basic call

The simplest case is "the page is doing things on its own and I want to watch the next interesting request go by" — e.g. an SPA that polls an endpoint in the background, or a flow already driven by some other code. You just call WaitForAnyRequest with one or more patterns and read the result:

idx, req, err := browser.WaitForAnyRequest(ctx, 5000, []wrc.RequestPattern{
  {URL: "*/api/*"},
})
if err != nil {
  log.Fatal(err)
}
fmt.Println("matched", idx, ":", req.Method, req.Url)

That's the whole API surface — one call, one result, no concurrency. If a matching request fires within the timeout, you get it; if not, the call returns an error.

Arm before you trigger

The basic call works fine when the trigger is outside your script. The moment you trigger the request — by clicking a button, calling Navigate, running Evaluate, etc. — ordering becomes the whole game.

The naive version looks like this:

// WRONG: race between the click and the wait registering.
_, _ = browser.Click(ctx, wrc.CSS("button#login"))
idx, req, err := browser.WaitForAnyRequest(ctx, 5000, []wrc.RequestPattern{
  {URL: "*/api/login"},
})

By the time WaitForAnyRequest registers its interceptor on the server, the login request may already have flown by — you'll time out on a request you saw with your own eyes.

The fix is register the interceptor first, then trigger, then read the result. In TypeScript that's a non-awaited promise; in Go it's a goroutine plus a channel — same shape, different syntax:

type result struct {
  req *wrc.InterceptedRequest
  err error
}
done := make(chan result, 1)
go func() {
  _, req, err := browser.WaitForAnyRequest(ctx, 5000, []wrc.RequestPattern{
      {URL: "*/api/login"},
  })
  done <- result{req, err}
}()

_, _ = browser.Click(ctx, wrc.CSS("button#login"))

r := <-done
if r.err != nil {
  log.Fatal(r.err)
}
fmt.Println("matched:", r.req.Method, r.req.Url)

A few things worth noting:

  • Click is a network-trigger, but so is Navigate, Fill (which may fire input-driven XHRs), key presses, and arbitrary Evaluate calls. The "arm before trigger" rule applies to any action that could fire the request you want to catch.
  • In Go, the goroutine takes a tiny moment to schedule and call into the server. For pages where the click immediately fires the request (no JS hop), prefer arming the wait before even navigating to the page — or chain a quick Wait for the button itself between the goroutine start and the click. The brief window is usually a non-issue, but it's worth knowing about for very fast flows.
  • The same pattern works for WaitForAnyResponse and ModifyRequest. Both are one-shot interceptors and both lose races for the same reason.

The captured request

Once the wait resolves, InterceptedRequest carries:

FieldWhat it holds
MethodHTTP verb (GET, POST, PUT, …).
UrlFull request URL the browser actually sent.
Headers[]Header (name/value pairs). Order preserved.
BodyPOST/PUT/PATCH body as a string. Empty for non-body requests.
ResourceTypeBrowser classification — Document, XHR, Fetch, Script, Image, Stylesheet, etc. Useful for filtering after the fact.

Drop a request mid-flight with Abort

Pass Abort: true on a pattern and the matching request gets dropped before it leaves the browser, with the page seeing an empty 200 OK in its place. Useful for stubbing an API while you assert against something else:

idx, req, _ := browser.WaitForAnyRequest(ctx, 5000, []wrc.RequestPattern{
  {URL: "*/api/telemetry", Abort: true},     // drop
  {URL: "*/api/login"},                      // observe
})
fmt.Println("idx", idx, "url", req.Url)

Abort is per-pattern: in a multi-pattern wait you can drop some matches and pass others through. The page never learns the request was intercepted — it just sees an empty 200.

Observe a single response with WaitForAnyResponse

Same shape, response phase. You get the status, headers and body the origin actually returned:

idx, resp, err := browser.WaitForAnyResponse(ctx, 5000, []wrc.RequestPattern{
  {URL: "*/api/me"},
})
if err != nil {
  log.Fatal(err)
}
fmt.Println("status", resp.StatusCode, "body bytes:", len(resp.Body))
_ = idx

Abort: true here replaces the real response with an empty 200 after the request reached the origin — handy for "let the API record the call but don't let the page see the data". Bodies are captured as strings; very large payloads are truncated server-side at ~10 MB.

Modify a request before it goes out

ModifyRequest is WaitForAnyRequest + rewrite. It blocks for the next request matching one URL pattern, applies your header changes and/or body replacement, then forwards the modified request to the network. It is one-shot.

Build modifications with the header helpers — they read like a sentence:

req, err := browser.ModifyRequest(ctx, "*/api/me", "", 5000, []wrc.HeaderModification{
  wrc.AddHeader("X-Trace", "abc123").Before("Cookie"),
  wrc.EditHeader("User-Agent", "MyAgent/1.0"),
  wrc.RemoveHeader("Accept-Language"),
})
if err != nil {
  log.Fatal(err)
}
fmt.Println("forwarded headers:", req.Headers)

The three header builders cover the lifecycle:

BuilderWhat it doesNotes
AddHeader(name, value)Inserts a new header.Use .Before(name) / .After(name) (Go) or .beforeHeader(name) / .afterHeader(name) (TS) to control insertion position. Without an anchor, appended at the end.
EditHeader(name, value)Overwrites an existing header.No-op if the request never carried the header — use AddHeader instead.
RemoveHeader(name)Drops the header from the outgoing request.Use to strip Cookie, tracking headers, or any other site-set field.

Replace the body too

Pass a non-empty body (Go positional / TS opts) to swap the POST/PUT/PATCH body for whatever you want. The Content-Length and related headers are recomputed by the browser. Leave empty to keep the original body.

_, _ = browser.ModifyRequest(ctx, "*/api/checkout",
  `{"sku":"test","quantity":1}`,
  5000, nil,
)

The return value is the modified InterceptedRequest — the headers and body that actually went on the wire after your changes were applied, so you can assert against them or log them.

Combining the levers — practical recipes

Drop ads and analytics for the whole session, then capture one specific API hit — block list is persistent, so it goes once at the top; the response wait is one-shot and armed before the click:

_ = browser.SetBlockList(ctx, []string{
  "*.doubleclick.net/*", "*googletagmanager.com*", "*.hotjar.com/*",
})

type respResult struct {
  resp *wrc.InterceptedResponse
  err  error
}
done := make(chan respResult, 1)
go func() {
  _, r, err := browser.WaitForAnyResponse(ctx, 5000, []wrc.RequestPattern{
      {URL: "*/api/orders"},
  })
  done <- respResult{r, err}
}()

_, _ = browser.Click(ctx, wrc.CSS("button#submit"))

r := <-done
if r.err != nil {
  log.Fatal(r.err)
}
fmt.Println("order id:", parseOrderId(r.resp.Body))

Inject an auth token into one outgoing API call without touching the page's JS:

done := make(chan error, 1)
go func() {
  _, err := browser.ModifyRequest(ctx, "*/api/me", "", 5000,
      []wrc.HeaderModification{wrc.AddHeader("Authorization", "Bearer " + token)},
  )
  done <- err
}()
_, _ = browser.Click(ctx, wrc.CSS("button#load-profile"))
if err := <-done; err != nil {
  log.Fatal(err)
}

Same shape as the basic example — arm first, trigger, await — just with ModifyRequest in the wait slot.

Gotchas

  • One-shot means one-shot. WaitForAnyRequest, WaitForAnyResponse, ModifyRequest consume exactly the next match. If you want to keep intercepting, call them again — they're cheap to re-arm.
  • SetBlockList / SetStaticPaths replace, not append. Keep the authoritative list in your script and resend the whole thing when it changes.
  • Arm before you trigger — covered above. If a network wait keeps timing out on a request you know fires, it's almost always because the click happened before the interceptor was registered.
  • Abort returns an empty 200. The page does not see a network error. Sites that branch on response shape rather than HTTP status will likely treat the empty body as "request succeeded, no data".
  • No regex, no anchors. Patterns are wildcards on the full URL. * matches any character span; everything else is literal.
  • Response bodies are truncated at ~10 MB. Don't rely on WaitForAnyResponse to mirror multi-megabyte downloads — it's an observation tool, not a download endpoint.
  • EditHeader is a no-op when the header is missing. If you want the header to exist regardless of the original request, prefer AddHeader (or call RemoveHeader + AddHeader to force-replace it).
  • Before/After only affect AddHeader. They're ignored on EditHeader / RemoveHeader because there's nothing to position.
See also