Intercepting WebSocket Traffic From a Browser Extension

Sep 19, 202613 min readbrowser-extensions

I had a browser extension that watches what a web app sends to its backend. It hooks window.fetch and XMLHttpRequest in the page, reads the request body before it leaves, reads the response as it streams back, and hands the result to something that cares. It worked on four AI chat apps in a row, and I was starting to feel clever.

Then I pointed it at a fifth one and got nothing. Not an error. Nothing. The kind of nothing that makes you check whether the extension is even loaded.

DevTools explained it in one row: the app opened a single WebSocket at page load and did everything over it. No fetch. No XHR. My hook was standing guard at a door nobody used.

This is what I learned teaching the extension to see WebSocket traffic: where you have to stand to patch it, which of the three patterns everyone copies actually works, how to read a streamed protocol back into messages, and how to test all of it without the real backend. There's a live demo halfway down that runs the real hook in your browser.

Why the fetch hook can't see it#

A WebSocket isn't a request/response pair. It's one long-lived connection where either side sends frames whenever it likes. There is no Response object to clone, no body to read, and the page never calls fetch again after the upgrade.

The browser API is small:

ts
const ws = new WebSocket("wss://assistant.example/api/chat")
ws.send('{"event":"send","text":"hi"}')              // outbound
ws.onmessage = (ev) => render(ev.data)               // inbound, style A
ws.addEventListener("message", (ev) => log(ev.data)) // inbound, style B

That's the whole thing. To observe traffic you need to see send() calls and message events. Both belong to the page's own objects, so the hook has to live where the page lives.

Where you have to stand#

Chrome extensions have two places to run code on a page. The isolated world is the default for content scripts: it shares the DOM with the page but has its own JavaScript globals. Patching WebSocket there patches a copy the page never touches, which is a very tidy way to accomplish nothing. The main world (world: "MAIN" in the manifest) is the page's own realm. Same window, same WebSocket.

Timing matters as much as world. If the page's bundle has already run new WebSocket(...) by the time your script loads, swapping window.WebSocket afterwards changes nothing for that socket. So the hook runs at document_start, before any page script gets a turn.

ts
// content script manifest entry
{
  matches: ["https://assistant.example/*"],
  world: "MAIN",
  runAt: "document_start",
  allFrames: true,   // an iframe is a separate realm with its own WebSocket
}

Main-world code can't call chrome.runtime. So the shape is: patch in the main world, emit a CustomEvent on window, catch it in an isolated-world script, forward with chrome.runtime.sendMessage. That relay already existed for the fetch hook. The WebSocket hook just walks through the same door.

The three patterns people copy#

Search "intercept websocket extension" and you get three shapes. They differ on one axis: how the inbound frame is seen.

Pattern 1: patch the prototype. The most-copied gist on the internet.

ts
const send = WebSocket.prototype.send
WebSocket.prototype.send = function (data) { observe("out", data); return send.call(this, data) }
 
const add = WebSocket.prototype.addEventListener
WebSocket.prototype.addEventListener = function (type, cb, ...rest) {
  const wrapped = type === "message" ? (ev) => { observe("in", ev.data); cb(ev) } : cb
  return add.call(this, type, wrapped, ...rest)
}

Simple, and it has one real advantage: it changes methods every socket shares, so it works on sockets created before you installed it. But it only wraps addEventListener. A page that does ws.onmessage = fn is never seen. Whether your target app uses one or the other, you don't know until you look, and a bundler can change it between releases without telling anyone.

Pattern 2: wrap the constructor. What wsHook and the WhatsApp Web incognito extension do. Replace window.WebSocket with a function that constructs the real socket, then wraps send, wraps addEventListener, and defines an onmessage setter that wraps whatever the page assigns. Covers both inbound styles. It's the heavyweight option because those tools want to rewrite frames before the page sees them, and rewriting needs a wrapper around the page's callback.

Pattern 3: subclass and register first. What I ended up with.

ts
const Native = window.WebSocket
window.WebSocket = class extends Native {
  constructor(url, protocols) {
    super(url, protocols)
    super.addEventListener("message", (ev) => observe("in", ev.data))  // listener #1
  }
  send(data) {
    observe("out", data)   // synchronous: seen before the wire
    super.send(data)
  }
}

The trick is in the constructor. DOM listeners fire in registration order, and ours is added before the constructor returns, so before the page can add anything. ws.onmessage = fn doesn't get around that either: an event-handler attribute joins the listener list when it's first assigned, which is necessarily after construction. We're first, for both inbound styles, without touching the page's handlers at all.

Don't take my word for it. This is the hook above, installed on this page, talking to a public echo server:

The hook, running on this page right now

Install the hook, open a socket to a public echo server, send a frame. Or open the socket first and watch the hook miss everything. Only sockets to wss://echo.websocket.org are observed; the native class is restored when you leave.

hook: off · socket: idle
nothing yet

The log numbers are the proof. For every inbound frame, the hook line has a lower number than the page line, and the page here listens with ws.onmessage, the style Pattern 1 can't see. Now hit reset, open the socket before installing the hook, and send again. The hook logs nothing. That's the timing dependency, and it's the reason for document_start.

The comparison across all three patterns, including the case where the hook installs late:

Who sees the frame, and who sees it first
Hook strategy

Replace WebSocket.prototype.send and .addEventListener with wrappers.

How the page listens
Hook installed
ws.send(frame)hook saw it before the wire
'message' eventran: page; hook never ran
The page assigned ws.onmessage. That never calls addEventListener, so the wrapper is never involved. Outbound is still seen because send() is shared.

Patterns 1 and 2 wrap the page's callbacks because they want to mutate data. If you only observe, you don't need the page's handlers; you need to be first in the list. That's why 3 is both the smallest and the one that closes the onmessage gap. The price is the timing dependency, and at document_start it never comes due.

instanceof WebSocket still holds because it's a real subclass, and uninstall() is window.WebSocket = Native.

Don't let the token leave the page#

The socket URL in my case looked like this:

code
wss://assistant.example/api/chat?v=2&accessToken=eyJhbGciOiJSUzI1NiIs...

A bearer JWT, in the query string. The browser's WebSocket API can't set headers, so web clients that need auth on the upgrade put it in the URL. The URL you observe in the constructor is therefore a credential, and a credential with the user's email in the payload if you decode it, which I did, once, and then stopped.

Whatever you record about the socket, for logging, for matching, for anything, strip the query before it goes anywhere. Do it in the hook, not in per-app code, so nobody can forget:

ts
function withoutQuery(url: string) {
  const u = new URL(url, location.href)
  return u.origin + u.pathname
}
What the extractor is allowed to see
matchedregistry host assistant.example
url passed onwss://assistant.example/api/chat
The query is gone before any provider code runs. Registered hosts here: assistant.example, chat.example.

The same spot gates which sockets are looked at: only wss:, only hosts you registered. Everything else passes through untouched.

Reading a stream back into messages#

Seeing frames is the easy half. The app I was looking at multiplexed everything over one socket: connection handshake, a bot challenge, keepalives, the streamed reply in fragments, a title update for its sidebar. Reconstructed from DevTools, one turn looked like this:

DirectionFrameMeaning
out{event:"send", conversationId, content:[{type:"text",text}]}the user's prompt
in{event:"connected"}, {event:"challenge"}, {event:"ping"}plumbing
in{event:"appendText", messageId, partId, text} × manyreply, streamed
in{event:"done", messageId}reply finished
in{event:"titleUpdate", conversationId, title}sidebar title

Two things make this harder than an HTTP response.

There's no pairing. The reply's appendText frames carry a messageId but not the conversationId the send carried. Nothing on the wire says "this reply answers that send." You remember the last send you saw and bind the first frame of a new messageId to it. That works because the UI blocks input while a reply streams, so there's one in flight at a time. It's an assumption. I wrote it down as one, right next to the code, so future me has someone to blame.

Emit on completion, not per frame. Buffer appendText by messageId and emit one assistant turn on done. Per-chunk emits would give you dozens of half-messages and a very confused inventory.

Turning a frame stream into two turns
frame 0 / 9
Frames
{"event":"connected","requestId":"8f1c…"}
{"event":"send","conversationId":"c_42","content":[{"type":"text","text":"hi"}]}
{"event":"appendText","messageId":"m_7","partId":"p_1","text":"Try [deep"}
{"event":"appendText","messageId":"m_7","partId":"p_1","text":" thinking](app://s?q=deep_thinking)"}
{"event":"appendText","messageId":"m_7","partId":"p_1","text":" tonight."}
{"event":"partCompleted","messageId":"m_7","partId":"p_1"}
{"event":"done","messageId":"m_7"}
{"event":"titleUpdate","conversationId":"c_42","title":"Late night"}
{"event":"ping"}
Extractor state
pending: null
buffers: {}
emitted
nothing yet
Step through the frames. Watch when a turn is emitted and when nothing happens.

Frames 3 and 4 split a markdown link across a boundary: [deep in one frame, thinking](app://…) in the next. That happened in real traffic. It's why any transform on the text (I strip those app-private suggestion links to their label, since the user never sees the target) has to run on the joined text in done, never per frame.

Notice also what happens to connected, challenge, partCompleted, titleUpdate, ping: nothing. The extractor has a table of the two events it consumes. Everything else is ignored by omission rather than by an ever-growing chain of if.

The extractor contract#

My extractors used to share one interface: matches(req), extractRequest(req), extractResponse(partial, response). All HTTP-shaped. The first version of the socket extractor implemented all three as stubs returning false and null, then added two optional socket methods. The interceptor had to defend against half-implementations, e.matchesSocket?.(…) && e.onSocketFrame, and reach for a non-null assertion to get past the compiler.

That's the interface lying. A provider speaks one transport. Say so:

ts
interface HttpExtractor   { matches(req): boolean; extractRequest(req): Session | null; extractResponse(partial, res): Promise<Session | null> }
interface SocketExtractor { matchesSocket(host, url): boolean; onSocketFrame(frame): Session | null }
type Extractor = HttpExtractor | SocketExtractor
 
const isHttp   = (e: Extractor): e is HttpExtractor   => "matches" in e
const isSocket = (e: Extractor): e is SocketExtractor => "matchesSocket" in e

The finders return the narrowed type, the stubs disappear, and the compiler lists every call site that had been leaning on the weak contract. That's the real payoff of a union over optional methods: "places I should remember to check" becomes "places tsc points at."

Testing without the real backend#

Two layers, neither of which needs the actual app or the actual server.

The hook, in Node. The unit-test environment has EventTarget, which is all a fake native socket needs:

ts
class FakeWebSocket extends EventTarget {
  sent: unknown[] = []
  constructor(public url: string) { super() }
  send(d: unknown) { this.sent.push(d) }
  set onmessage(fn) { this.addEventListener("message", fn) }   // mirrors the DOM
  receive(d: unknown) { this.dispatchEvent(new MessageEvent("message", { data: d })) }
}

Shim window.WebSocket = FakeWebSocket, install the hook, and assert the claims directly: our observer runs before a listener the page adds with onmessage, and before one added with addEventListener; send is observed before it reaches sent; the query is gone from what the extractor sees; binary frames pass through unread; an exception inside the extractor never propagates into the page's send().

Then break something on purpose. I changed the query strip to u.href and four tests went red, not just the strip test, because the extractor stopped matching the URL entirely. A test that has never been red hasn't told you anything yet.

The extractor, on captured frames. Paste real frames from DevTools into fixtures. The two that matter most: a reply with no preceding send (dropped, nothing to attach it to), and a reply that blows past a size cap (dropped and logged, not truncated, because a stream that never sends done would otherwise grow the buffer until the tab falls over).

End to end, with a stub. Instead of the real local endpoint the extension talks to, a 40-line Node HTTP server that answers the discovery probe and prints every session it receives. Load the extension, open the app, send one prompt, read stdout. That single run answered the question no unit test could: is the socket opened by the page, or by a Web Worker? A worker has its own WebSocket global, and window.WebSocket patching would never see it. It was the page. If it hadn't been, the hook would have had to move house.

Things that bit, or nearly did#

  • Bot challenges. Replaying the protocol from outside the browser, in Node, got a challenge frame and then silence. The page answers it with a proof-of-work exchange I have no interest in reverse-engineering. Observing from inside the browser that already passed the challenge is the whole point of the technique.
  • Origin on privileged fetches. A background service worker's fetch to a URL covered by host_permissions is "privileged": Chrome drops the Origin header. If your local endpoint validates callers by origin, that breaks silently. A custom request header forces a CORS preflight, which puts Origin back. Unrelated to WebSockets. It still cost me an hour.
  • Binary frames. ev.data can be a Blob or an ArrayBuffer. Guard on typeof === "string" and let the rest through.
  • Per-frame transforms. Already covered, but it's the bug I'd bet on someone reintroducing.
  • Firefox. world: "MAIN" needs Firefox 128 or newer. Same code path otherwise.

What I'd tell past me#

Three sentences would have saved a day.

The fetch hook and the WebSocket hook are the same idea at different doors: stand in the page world before the page runs, observe, relay. Register first instead of wrapping the page's handlers; it's less code and it doesn't care how the page listens. And treat the socket URL as a credential from the moment you see it.