Reference
Request shape
The init argument is the standard RequestInit (opens in a new tab). Headers, method, and body all work. AbortController (signal) is not supported: an in-flight request cannot be cancelled.
const res = await mixFetch('https://httpbin.org/post', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ hello: 'mixnet' }),
});
console.log(await res.json());Binary responses come back via the standard Response.arrayBuffer() / Response.blob() methods:
const res = await mixFetch('https://example.com/image.png');
const blob = await res.blob();Repeated headers (Set-Cookie, Vary, Link, WWW-Authenticate) are preserved. The wasm side returns headers as a [name, value] pair sequence, which Headers reconstructs verbatim.
Default request headers
When the caller doesn't set them, mixFetch injects four browser-shape headers before the request leaves the tunnel. The shim exists because many CDNs (cloudflare's bot management) and host policies (wikimedia's User-Agent policy) reject requests that look unlike a real browser. Caller-supplied values always win.
| Header | Injected default |
|---|---|
User-Agent | Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 |
Accept | text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8 |
Accept-Language | en-US,en;q=0.9 |
Accept-Encoding | identity |
Accept-Encoding is forced to identity rather than gzip, deflate, br because the wasm build has no decompressor. Advertising compression would let the server return a compressed body the wasm build cannot decode, so Response.text() or .json() would see raw compressed bytes. Responses therefore arrive uncompressed, so large text or JSON bodies transfer more bytes over the slower mixnet path.
To override any of these, set the header in the init.headers bag like normal:
const res = await mixFetch('https://example.com', {
headers: { 'User-Agent': 'my-app/1.0' },
});The shim does not attempt full browser impersonation. TLS fingerprint (JA3), HTTP/2, and header ordering are still distinguishable from a real Chrome request. If you need stronger blend-in, you'll need to handle that at the application or destination layer.
Drop-in caveats
mixFetch matches the fetch() call signature but is not a perfect substitute. The differences are intentional and follow from running outside the browser's networking stack:
| Difference | What it means | What to do |
|---|---|---|
| No same-origin restriction | Requests aren't subject to browser CORS preflight. The IPR honours its exit policy regardless of Origin. | Don't rely on CORS as an access-control mechanism for mixFetch requests; treat them as you would server-to-server calls. |
| No cookies / credentials | The browser cookie jar is not shared with the WASM instance. credentials: 'include' has no effect. | Pass auth tokens via Authorization or other explicit headers. |
| No HTTP cache | The browser HTTP cache is not consulted. Every call hits the network. | Cache responses at the application layer if needed. |
| No service-worker interception | Requests don't pass through any fetch event handlers registered by service workers. | n/a |
| HTTPS only in practice | The IPR sees plaintext HTTP in full. | Always target https:// URLs. |
Errors
mixFetch follows fetch semantics for HTTP status: a 4xx or 5xx response resolves with a Response carrying that status, so check response.ok or response.status yourself. The promise rejects only on a transport-level failure: a connection or TLS failure, a DNS failure, or the IPR refusing the destination under its exit policy. A rejection is a plain Error whose message describes the cause; there is no typed error class, so match on the message if you need to branch.
Timeouts and cancellation
There is no per-request timeout, and AbortController / signal is ignored: an in-flight mixFetch cannot be cancelled. To bound how long you wait, race it against a timer. This stops you waiting but does not cancel the underlying request:
const res = await Promise.race([
mixFetch(url),
new Promise<Response>((_, reject) =>
setTimeout(() => reject(new Error('mixFetch timeout')), 30_000),
),
]);Connection and DNS timeouts at the tunnel level are set once via connectTimeoutMs and dnsTimeoutMs in SetupMixTunnelOpts.
Configuration
setupMixTunnel(opts) (and createMixFetch(opts)) accept the shared tunnel options from @nymproject/mix-tunnel. The most commonly touched are:
await setupMixTunnel({
// Pin a specific IPR (otherwise auto-discovered from the topology).
preferredIpr: 'D1rrUqJY9pesL3pTaMaxLnpZGGYQ4ZpZwpQXCqaeBXTW.6PpFkRvF...',
// Lower latency and bandwidth at the cost of traffic-analysis resistance.
disableCoverTraffic: true,
disablePoissonTraffic: true,
});The full option surface is documented under SetupMixTunnelOpts.
The first mixFetch call after setupMixTunnel() may take a few seconds: gateway handshake, IPR discovery, and the first DNS resolution all happen on demand. Subsequent calls reuse the tunnel and complete in roughly the time of a normal HTTPS request plus mixnet latency.