Mocking third-party APIs

While you build a page that calls someone else's API, it helps when the page works without keys, network access or real data. In the playground, a worker can answer requests to other hosts: name the hosts in io:host, return a Response for the requests you want to fake, and return nothing for the rest, which go on to the real server. This article shows how to choose hosts, how to answer and pass through, and what io:fail does when the worker fails.

Note: io:host and io:fail work in the playground only. RTEdge.net ignores them, so a deployed page calls the real host.

Answering requests to another host

Put io:host on the first <script type=worker> of the page. The worker is then called for every request that the output makes to one of the listed hosts: fetch(), XHR, EventSource, images, scripts and styles. Inside the handler, request.url is the original URL on the other host, and request.rt is attached as usual. Requests made with fetch() keep their method, headers and body.

A script without edge runs only in the playground, which is where a mock belongs.

A mocked user API

The page asks api.example.com for a user. The worker answers the paths it knows and returns nothing for the others, so those reach the real host.

html
<pre id=profile></pre>
<script type=module>
  profile.textContent = JSON.stringify(await fetch('https://api.example.com/v1/users/7').then(r => r.json()))
</script>
<script type=worker io:host=api.example.com>
  export default {
    fetch: async request => {
      const { pathname } = new URL(request.url)
      if (pathname.startsWith('/v1/users/')) return Response.json({ id: +pathname.split('/').at(-1), name: 'Ada' })
    }
  }
</script>

You should see {"id":7,"name":"Ada"}. A synthetic Response that you return to a page fetch() in CORS mode is readable without any CORS headers.

Choosing hosts

io:host is a space-separated list. The editor lowercases it, collapses whitespace and removes duplicates, and the service worker turns each token into a host with new URL('https://' + token).host. That converts internationalized names to punycode, drops :443 and keeps other ports. Tokens that cannot be parsed are dropped, and so is an empty value.

A request matches when its host, with its port, is equal to a token, or when a token is *.

ValueWhat it matches
api.example.comRequests to api.example.com, whatever the path.
api.example.com cdn.example.comBoth hosts.
localhost:8787That host on port 8787 only. Other ports keep their number, and :443 is dropped.
bücher.deThe punycode host xn--bcher-kva.de.
*Every host that can be intercepted; see below.
*.example.comNothing: a wildcard subdomain stays literal and never matches.
https://api.example.comNothing useful: a scheme is not part of a host.
api.example.com/v1The whole host api.example.com: the path is dropped, so it cannot narrow the match.

Write hosts without trailing dots. For http:// requests, a :80 token does not match, because the host of such a request omits the port.

What cannot be intercepted

  • Only requests that the service worker of the output sees can be answered. Those are the subresource requests of the output documents and of their same-origin frames and workers.
  • Cross-origin navigations, such as <iframe src=//api.stripe.com> or a click on a link to another site.
  • Requests that the worker itself makes. A fetch() inside the worker goes to the network, and never back into your mock.
  • The platform's own runtime hosts, even with *. Requests to codepen.io also pass through natively, with a warning toast.

Answering and passing through

Warning: With io:host=*, a worker that always returns a Response also swallows CDN scripts, styles and fonts. Return undefined for the requests you do not handle.

Avoid mocking URLs whose path contains a :. The playground treats that colon like the worker prefix, so the worker sees a shortened path: a request for https://api.example.com/v1/x:batch arrives as https://api.example.com/batch.

Failing open or closed

io:fail on the first worker script decides what happens when the worker fails. Only the exact value open turns fail-open on. Any other value, an empty one or a different case included, means closed, which is the default.

Failureio:fail=closedio:fail=open
The handler's promise rejects with an ErrorHTTP 500 with text/plain and a filtered stack as the body.The original request goes to the network.
A synchronous throw, a compile or syntax error, an error in the top-level code, or a rejection with something that is not an ErrorA network error: fetch() rejects with a TypeError, and a navigation shows the browser's error page. It is not an HTTP 500.The original request goes to the network.

Failures are logged to the playground console as console.error with the 🛎️⚙️ prefix. ☁️ marks the closed mode and ⛅ the open mode, followed by a stack. Throw Error objects: other values are not logged reliably. The line numbers in the stack are approximate and are not editor line numbers.

Failing open

The handler throws, so ⛅ is logged and the request goes to the real api.stripe.com. The page then sees the real HTTP response. Without an API key that is a 401, which the page can read because Stripe echoes the request's Origin in access-control-allow-origin.

html
<script type=worker io:host=api.stripe.com io:fail=open>
  export default { fetch: r => new Response(undefined.url) }
</script>
<script type=module>
  console.log(await fetch('//api.stripe.com/v1/charges').then(r => r.status))
</script>

Mocks next to your own API

All worker scripts of a page form one program, and only the first script's io:host and io:fail count. A page that has its own edge worker therefore needs one handler for both jobs: put io:host on the first, edge script, and branch on the host. RTEdge.net ignores io:host, and an edge worker only receives requests for its own route, so the mock branch never runs there.

One handler, two jobs

html
<pre id=out></pre>
<script type=module>
  const user = await fetch('https://api.example.com/v1/users/7').then(r => r.json())
  const mine = await fetch(':stats').then(r => r.json())
  out.textContent = JSON.stringify({ user, mine }, null, 2)
</script>
<script type=worker edge io:host=api.example.com>
  export default {
    fetch: async request => {
      const { host, pathname } = new URL(request.url)
      if (host === 'api.example.com') return pathname.startsWith('/v1/users/') ? Response.json({ id: 7, name: 'Ada' }) : undefined
      return Response.json({ where: request.rt.edge ? 'edge' : 'playground' })
    }
  }
</script>

In the playground the worker answers both calls. After you deploy, it answers only :stats, and the page's call goes to the real API. The deployed worker still contains the mock branch, which is never reached.

Pitfalls

See also