#2782·msw

ws.link("ws://*") handlers never fire in Chromium/Firefox since 2.12.9 (wildcard host percent-encoded by new URL)

Author: brendankempCreated Sep 14, 2026Updated Sep 15, 2026

Prerequisites

  • I confirm my issue is not in the FAQ
  • I confirm my issue is not one of the known issues
  • I confirm my issue is not a question
  • I have searched the existing issues

Environment check

  • I'm using the latest msw version
  • I'm using TypeScript >= 4.7

Browsers

Chromium (Chrome), Firefox — WebKit is unaffected.

Reproduction repository

Minimal standalone repro (one HTML file, one module, no framework): attached below inline.

Reproduction steps

  1. Register a WebSocket handler with a wildcard host: ws.link("ws://*").
  2. await worker.start().
  3. Open a WebSocket to any host, e.g. new WebSocket("ws://localhost:5000/socket.io/?EIO=4&transport=websocket").

Current behavior

The connection listener never fires in Chromium or Firefox. It fires correctly in WebKit.

Interception itself is working in all three engines — globalThis.WebSocket is replaced (verified cross-realm against a blank iframe's pristine constructor), and the connection is intercepted. Only the handler match fails, so the connection falls through to passthrough and errors.

This is a regression. Bisected:

msw Chromium WebKit
2.6.0 / 2.10.0 / 2.12.0 / 2.12.7 works works
2.12.8 works works
2.12.9 broken works
2.12.10 / 2.12.14 / 2.13.0 / 2.15.0 broken works

2.12.9 contains a single change: ws: support relative urls in ws.link (#2661, 2f1d23c).

Cause

WebSocketHandler#parse now routes the handler URL through #resolveWebSocketUrl unless it is a RegExp or starts with *:

typescript
const resolvedHandlerUrl =
  this.url instanceof RegExp || this.url.startsWith('*')
    ? this.url
    : this.#resolveWebSocketUrl(this.url, args.resolutionContext?.baseUrl)

"ws://*" does not start with * — it starts with ws:// — so it is resolved through new URL(). Engines disagree on how a wildcard host is normalised:

javascript
new URL("ws://*").href
// Chromium: "ws://%2A/"   ← wildcard percent-encoded
// Firefox:  "ws://%2A/"
// WebKit:   "ws://*/"     ← wildcard preserved

So on Chromium/Firefox the handler's predicate becomes ws://%2A, which cannot match any real host. WebKit keeps * and continues to match, which is why a WebKit-only test suite stays green.

The startsWith('*') guard protects patterns like */path, but not a wildcard that appears in the host after a scheme.

Expected behavior

ws.link("ws://*") matches any host on every engine, as it did up to 2.12.8.

Suggested fix

Decode the wildcard back after resolution, which keeps the relative-URL support added in #2661:

typescript
// in #resolveWebSocketUrl, alongside the existing trailing-slash strip
return resolvedUrl.replace(/%2A/gi, '*').replace(/\/$/, '')

Widening the guard to this.url.includes('*') would also fix the symptom, but would skip resolution for relative patterns containing a wildcard (/socket.io/*), which #2661 intends to resolve against the base URL.

I have not run this against the msw test suite — happy to open a PR if the approach looks right.

Repro source

main.js:

javascript
import { ws } from "msw"
import { setupWorker } from "msw/browser"

const link = ws.link("ws://*")
let fired = false
const handler = link.addEventListener("connection", () => {
  fired = true
  console.log("HANDLER FIRED")
})

const worker = setupWorker(handler)
await worker.start({ quiet: true })

// Cross-realm check that interception is in place at all.
const frame = document.createElement("iframe")
document.body.appendChild(frame)
console.log("WebSocket replaced:", globalThis.WebSocket !== frame.contentWindow.WebSocket)
frame.remove()

console.log('URL("ws://*").href =', new URL("ws://*").href)

new WebSocket("ws://localhost:5000/socket.io/?EIO=4&transport=websocket")

setTimeout(() => console.log(fired ? "handler fired" : "handler NEVER fired"), 4000)

localhost:5000 is deliberately a host that does not exist, so any successful connection can only have come from the mock.