Skip to content

Fetch handler mutates immutable response headers when window.fetch is wrapped (IS_MAYBE_MIRAGE false positive) #10540

Description

@st-h

Package: @warp-drive/core 5.8.2 (still present on main:
warp-drive-packages/core/src/request/-private/fetch.ts)
Affects: DEBUG builds only (the heuristic compiles to () => false in production).

Summary

The Fetch handler stamps a date header onto successful responses that lack one. It picks
between two strategies:

if (!isError && !isMutationOp && response.status !== 204 && !response.headers.has('date')) {
  if (IS_MAYBE_MIRAGE()) {
    response.headers.set('date', new Date().toUTCString());   // mutates in place
  } else {
    const headers = new Headers(response.headers);            // safe clone path
    headers.set('date', new Date().toUTCString());
    response = cloneResponse(response, { headers });
  }
}

IS_MAYBE_MIRAGE() is:

IS_MAYBE_MIRAGE = () =>
  Boolean(
    typeof window !== 'undefined' &&
    ((window as { server?: { pretender: unknown } }).server?.pretender ||
      window.fetch.toString().replace(/\s+/g, '') !== 'function fetch() { [native code] }'.replace(/\s+/g, ''))
  );

The toString() check treats any non-native window.fetch as "probably Mirage". But the
response being processed is still a real fetch() Response whose Headers carry the
immutable guard, so the in-place headers.set('date', ...) throws:

TypeError: Failed to execute 'set' on 'Headers': Headers are immutable

and the request rejects even though the network call succeeded.

Real-world triggers (none of them Mirage)

  • SSR shoebox/replay wrappers that temporarily patch window.fetch during hydration
    (how we hit it: vite-ember-ssr installs one until its cached entries are consumed)
  • error/performance instrumentation that wraps fetch (Rollbar, Sentry, APM agents)
  • browser extensions injecting main-world fetch wrappers
  • core-js URL polyfills bundled by third-party scripts, which force-replace fetch

Two properties make this painful to debug:

  1. It's flaky. IS_MAYBE_MIRAGE() is evaluated per response, so it races the
    wrapper's install/uninstall. In our app the same reload sometimes crashed and sometimes
    didn't, depending on whether the shoebox drained before the API response landed.
  2. The error loses its identity. cloneError() rebuilds rejections as plain
    new Error(message), so callers see Error, not TypeError, and the console points at
    the store call site rather than the Fetch handler.

Note the precondition !response.headers.has('date') is met by every cross-origin API
response unless the server explicitly sends Access-Control-Expose-Headers: DateDate
is not a CORS-safelisted response header. So for a typical dev setup (app origin ≠ API
origin), any wrapped fetch turns every successful GET into a rejection.

Reproduction

// in any DEBUG build app:
const orig = window.fetch;
window.fetch = function fetch(...args) { return orig.apply(this, args); }; // transparent wrapper

// any successful non-mutation store.request() whose response has no readable
// `date` header now rejects with:
// TypeError: Failed to execute 'set' on 'Headers': Headers are immutable

Suggested fix

Feature-detect instead of fingerprinting — attempt the in-place set and fall back to the
clone path when the headers are guarded:

if (!isError && !isMutationOp && response.status !== 204 && !response.headers.has('date')) {
  try {
    // Mirage/Pretender-style fake Responses have mutable headers
    response.headers.set('date', new Date().toUTCString());
  } catch {
    // real fetch Response: Headers guard is "immutable"
    const headers = new Headers(response.headers);
    headers.set('date', new Date().toUTCString());
    response = cloneResponse(response, { headers });
  }
}

This removes the IS_MAYBE_MIRAGE fingerprint entirely for this branch (the streaming
check below it could keep it, or use a capability check as well). Happy to send a PR if
you'd take this shape.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions