diff --git a/MIGRATING.md b/MIGRATING.md
new file mode 100644
index 000000000..8ddc0e003
--- /dev/null
+++ b/MIGRATING.md
@@ -0,0 +1,659 @@
+# Migration guide
+
+This guide will help you migrate from the latest version of MSW to the `next` release that introduces a first-class support for Fetch API primitives to the library. **This is a breaking change**. In fact, this is the biggest change to our public API since the day the library was first published. Do not fret, however, as this is precisely why this document exists.
+
+## Getting started
+
+```sh
+npm install msw@next --save-dev
+```
+
+## Table of contents
+
+To help you navigate, we've structured this guide on the feature basis. You can read it top-to-bottom, or you can jump to a particular feature you have trouble migrating from.
+
+- [**Imports**](#imports)
+- [**Response resolver**](#response-resolver) (call signature change)
+- [Request changes](#request-changes)
+- [req.params](#reqparams)
+- [req.cookies](#request-cookies)
+- [req.passthrough](#reqpassthrough)
+- [res.once](#resonce)
+- [res.networkError](#resnetworkerror)
+- [Context utilities](#context-utilities)
+ - [ctx.status](#ctxstatus)
+ - [ctx.set](#ctxset)
+ - [ctx.cookie](#ctxcookie)
+ - [ctx.body](#ctxbody)
+ - [ctx.text](#ctxtext)
+ - [ctx.json](#ctxjson)
+ - [ctx.xml](#ctxxml)
+ - [ctx.data](#ctxdata)
+ - [ctx.errors](#ctxerrors)
+ - [ctx.delay](#ctxdelay)
+ - [ctx.fetch](#ctx-fetch)
+- [Life-cycle events](#life-cycle-events)
+- [`.printHandlers()`](#print-handlers)
+- [Advanced](#advanced)
+- [**What's new in this release?**](#whats-new)
+- [Common issues](#common-issues)
+
+---
+
+## Imports
+
+### `rest` becomes `http`
+
+The `rest` request handler namespace has been renamed to `http`.
+
+```diff
+-import { rest } from 'msw'
++import { http } from 'msw'
+```
+
+This affects the request handlers declaration as well:
+
+```js
+import { http } from 'msw'
+
+export const handlers = [
+ http.get('/resource', resolver),
+ http.post('/resource', resolver),
+ http.all('*', resolver),
+]
+```
+
+### Browser imports
+
+The `setupWorker` API, alongside any related type definitions, are no longer exported from the root of `msw`. Instead, import them from `msw/browser`:
+
+```diff
+-import { setupWorker } from 'msw'
++import { setupWorker } from 'msw/browser'
+```
+
+> Note that the request handlers like `http` and `graphql`, as well as the utility functions like `bypass` and `passthrough` must still be imported from the root-level `msw`.
+
+## Response resolver
+
+A response resolver now exposes a single object argument instead of `(req, res, ctx)`. That argument represents resolver information and consists of properties that are always present for all handler types and extra properties specific to handler types.
+
+### Resolver info
+
+#### General
+
+- `request`, a Fetch API `Request` instance representing an intercepted request.
+- `cookies`, a parsed cookies object based on the request cookies.
+
+#### REST-specific
+
+- `params`, an object of parsed path parameters.
+
+#### GraphQL-specific
+
+- `query`, a GraphQL query string extracted from either URL search parameters or a POST request body.
+- `variables`, an object of GraphQL query variables.
+
+### Using a new signature
+
+To mock responses, you should now return a Fetch API `Response` instance from the response resolver. You no longer need to compose a response via `res()`, and all the context utilities have also [been removed](#context-utilities).
+
+```js
+http.get('/greet/:name', ({ request, params }) => {
+ console.log('Intercepted %s %s', request.method, request.url)
+ return new Response(`hello, ${params.name}!`)
+})
+```
+
+Now, a more complex example for both REST and GraphQL requests.
+
+```js
+import { http, graphql } from 'msw'
+
+export const handlers = [
+ http.put('/user/:id', async ({ request, params, cookies }) => {
+ // Read request body as you'd normally do with Fetch.
+ const payload = await request.json()
+ // Access path parameters like before.
+ const { id } = params
+ // Access cookies like before.
+ const { sessionId } = cookies
+
+ return new Response(null, { status: 201 })
+ }),
+
+ graphql.mutation('CreateUser', ({ request, query, variables }) => {
+ return new Response(
+ JSON.stringify({
+ data: {
+ user: {
+ id: 'abc-123',
+ firstName: variables.firstName,
+ },
+ },
+ }),
+ {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ },
+ )
+ }),
+]
+```
+
+### Request changes
+
+Since the returned `request` is now an instance of Fetch API `Request`, there are some changes to its properties.
+
+#### Request URL
+
+The `request.url` property is a string (previously, a `URL` instance). If you wish to operate with it like a `URL`, you need to construct it manually:
+
+```js
+http.get('/product', ({ request }) => {
+ // For example, this is how you would access
+ // request search parameters now.
+ const url = new URL(request.url)
+ const productId = url.searchParams.get('id')
+})
+```
+
+#### `req.params`
+
+Path parameters are now exposed directly on the [Resolver info](#resolver-info) object (previously, `req.params`).
+
+```js
+http.get('/resource', ({ params }) => {
+ console.log('Request path parameters:', params)
+})
+```
+
+#### `req.cookies`
+
+Request cookies are now exposed directly on the [Resolver info](#resolver-info) object (previously, `req.cookies`).
+
+```js
+http.get('/resource', ({ cookies }) => {
+ console.log('Request cookies:', cookies)
+})
+```
+
+#### Request body
+
+The library now does no assumptions when reading the intercepted request's body (previously, `req.body`). Instead, you are in charge to read the request body as you see appropriate.
+
+> Note that since the intercepted request is now represented by a Fetch API `Request` instance, its `request.body` property still exists but returns a `ReadableStream`.
+
+For example, this is how you would read request body:
+
+```js
+http.post('/resource', async ({ request }) => {
+ const data = await request.json()
+ // request.formData() / request.arrayBuffer() / etc.
+})
+```
+
+### Convenient response declarations
+
+Using the Fetch API `Response` instance may get quite verbose. To give you more convenient means of declaring mocked responses while remaining specification compliant and compatible, the library now exports an `HttpResponse` object. You can use that object to construct response instances faster.
+
+```js
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/user', () => {
+ // This is synonymous to "ctx.json()":
+ // HttpResponse.json() stringifies the given body
+ // and sets the correct "Content-Type" response header
+ // to describe a JSON response body.
+ return HttpResponse.json({ firstName: 'John' })
+ }),
+]
+```
+
+> Read more on how to use `HttpResponse` to mock [REST API](#rest-response-body-utilities) and [GraphQL API](#graphql-response-body-utilities) responses.
+
+## Responses in Node.js
+
+Although MSW now respects the Fetch API specification, the older versions of Node.js do not, so you can't construct a `Response` instance because there is no such global class.
+
+To account for this, the library exports a `Response` class that you should use when declaring request handlers. Behind the hood, that response class is resolved to a compatible polyfill in Node.js; in the browser, it only aliases `global.Response` without introducing additional behaviors.
+
+```js
+import { http,Response } from 'msw'
+
+setupServer(
+ http.get('/ping', () => {
+ return new Response('hello world)
+ })
+)
+```
+
+Relying on a single universal `Response` class will allow you to write request handlers that can run in both browser and Node.js environments.
+
+## `res.once`
+
+To create a one-time request handler, pass it an object as the third argument with `once: true` set:
+
+```js
+import { HttpResponse, http } from 'msw'
+
+export const handlers = [
+ http.get(
+ '/user',
+ () => {
+ return HttpResponse.text('hello')
+ },
+ { once: true },
+ ),
+]
+```
+
+## `res.networkError`
+
+To respond to a request with a network error, use the `HttpResponse.error()` static method:
+
+```js
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.error()
+ }),
+]
+```
+
+> Note that we are dropping support for custom network error messages to be more compliant with the standard [`Response.error()`](https://developer.mozilla.org/en-US/docs/Web/API/Response/error_static) network errors, which don't support custom error messages.
+
+## `req.passthrough`
+
+```js
+import { http, passthrough } from 'msw'
+
+export const handlers = [
+ http.get('/user', () => {
+ // Previously, "req.passthrough()".
+ return passthrough()
+ }),
+]
+```
+
+---
+
+## Context utilities
+
+Most of the context utilities you'd normally use via `ctx.*` were removed. Instead, we encourage you to set respective properties directly on the response instance:
+
+```js
+import { HttpResponse, http } from 'msw'
+
+export const handlers = [
+ http.post('/user', () => {
+ // ctx.json()
+ return HttpResponse.json(
+ { firstName: 'John' },
+ {
+ status: 201, // ctx.status()
+ headers: {
+ 'X-Custom-Header': 'value', // ctx.set()
+ },
+ },
+ )
+ }),
+]
+```
+
+Let's go through each previously existing context utility and see how to declare its analogue using the `Response` class.
+
+### `ctx.status`
+
+```js
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.text('hello', { status: 201 })
+ }),
+]
+```
+
+### `ctx.set`
+
+```js
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.text('hello', {
+ headers: {
+ 'Content-Type': 'text/plain; charset=windows-1252',
+ },
+ })
+ }),
+]
+```
+
+### `ctx.cookie`
+
+```js
+import { HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.text('hello', {
+ headers: {
+ 'Set-Cookie': 'token=abc-123',
+ },
+ })
+ }),
+]
+```
+
+When you provide an object as the `ResponseInit.headers` value, you cannot specify multiple response cookies with the same name. Instead, to support multiple response cookies, provide a `Headers` instance:
+
+```js
+import { HttpResponse, http } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return new HttpResponse(null, {
+ headers: new Headers([
+ // Mock a multi-value response cookie header.
+ ['Set-Cookie', 'sessionId=123'],
+ ['Set-Cookie', 'gtm=en_US'],
+ ]),
+ })
+ }),
+]
+```
+
+> This is applicable to any multi-value headers, really.
+
+### `ctx.body`
+
+```js
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return new HttpResponse('any-body')
+ }),
+]
+```
+
+> Do not forget to set the `Content-Type` header that represents the mocked response's body type. If using common response body types, like text or json, see the respective migration instructions for those context utilities below.
+
+### `ctx.text`
+
+```js
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.text('hello')
+ }),
+]
+```
+
+### `ctx.json`
+
+```js
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.json({ firstName: 'John' })
+ }),
+]
+```
+
+### `ctx.xml`
+
+```js
+import { http, HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.xml('')
+ }),
+]
+```
+
+### `ctx.data`
+
+The `ctx.data` utility has been removed in favor of constructing a mocked JSON response with the "data" property in it.
+
+```js
+import { HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.json({
+ data: {
+ user: {
+ firstName: 'John',
+ },
+ },
+ })
+ }),
+]
+```
+
+### `ctx.errors`
+
+The `ctx.errors` utility has been removed in favor of constructing a mocked JSON response with the "errors" property in it.
+
+```js
+import { HttpResponse } from 'msw'
+
+export const handlers = [
+ http.get('/resource', () => {
+ return HttpResponse.json({
+ errors: [
+ {
+ message: 'Something went wrong',
+ },
+ ],
+ })
+ }),
+]
+```
+
+### `ctx.delay`
+
+```js
+import { http, HttpResponse, delay } from 'msw'
+
+export const handlers = [
+ http.get('/resource', async () => {
+ await delay()
+ return HttpResponse.text('hello')
+ }),
+]
+```
+
+The `delay` function has the same call signature as the `ctx.delay` context function. This means it supports the delay mode as an argument:
+
+```js
+await delay(500)
+await delay('infinite')
+```
+
+### `ctx.fetch`
+
+The `ctx.fetch()` function has been removed in favor of the `bypass()` function. You should now always perform a regular `fetch()` call and wrap the request in the `bypass()` function if you wish for it to ignore any otherwise matching request handlers.
+
+```js
+import { http, HttpResponse, bypass } from 'msw'
+
+export const handlers = [
+ http.get('/resource', async ({ request }) => {
+ // Use the regular "fetch" from your environment.
+ const originalResponse = await fetch(bypass(request))
+ const json = await originalResponse.json()
+
+ // ...handle the original response, maybe return a mocked one.
+ }),
+]
+```
+
+The `bypass()` function also accepts `RequestInit` as the second argument to modify the bypassed request.
+
+```js
+// Bypass the given "request" and modify its headers.
+bypass(request, {
+ headers: {
+ 'X-Modified-Header': 'true',
+ },
+})
+```
+
+---
+
+## Life-cycle events
+
+The life-cycle events listeners now accept a single argument being an object with contextual properties.
+
+```diff
+-server.events.on('request:start', (request, requestId) = {})
++server.events.on('request:start', ({ request, requestId}) => {})
+```
+
+The request and response instances exposed in the life-cycle API have also been updated to return Fetch API `Request` and `Response` respectively.
+
+The request ID is now exposed as a standalone argument (previously, `req.id`).
+
+```js
+server.events.on('request:start', ({ request, requestId }) => {
+ console.log(request.method, request.url)
+})
+```
+
+To read a request body, make sure to clone the request first. Otherwise, it won't be performed as it would be already read.
+
+```js
+server.events.on('request:match', async ({ request }) => {
+ // Make sure to clone the request so it could be
+ // processed further down the line.
+ const clone = request.clone()
+ const json = await clone.json()
+
+ console.log('Performed request with body:', json)
+})
+```
+
+The `response:*` events now always contain the response reference, the related request, and its id in the listener arguments.
+
+```js
+worker.events.on('response:mocked', ({ response, request, requestId }) => {
+ console.log('response to %s %s is:', request.method, request.url, response)
+})
+```
+
+---
+
+## `.printHandlers()
+
+The `worker.prinHandlers()` and `server.printHandlers()` methods were removed. Use the `.listHandlers()` method instead:
+
+```diff
+-worker.printHandlers()
++console.log(worker.listHandlers())
+```
+
+---
+
+## Advanced
+
+It is still possible to create custom handlers and resolvers, just make sure to account for the new [resolver call signature](#response-resolver).
+
+### Custom response composition
+
+As this release removes the concept of response composition via `res()`, you can no longer compose context utilities or abstract their partial composed state to a helper function.
+
+Instead, you can abstract a common response logic into a plain function that creates a new `Response` or modifies a provided instance.
+
+```js
+// utils.js
+import { HttpResponse } from 'msw'
+
+export function augmentResponse(json) {
+ const response = HttpResponse.json(json, {
+ // Come up with some reusable defaults here.
+ })
+ return response
+}
+```
+
+```js
+import { http } from 'msw'
+import { augmentResponse } from './utils'
+
+export const handlers = [
+ http.get('/user', () => {
+ return augmentResponse({ id: 1 })
+ }),
+]
+```
+
+---
+
+## What's new?
+
+The main benefit of this release is the adoption of Fetch API primitives—`Request` and `Response` classes. By handling requests and responses as the platform does it, you bring your API mocking setup to the next level. Less library-specific abstractions, flatter learning curve, improved compatibility with other tools. But, most importantly, specification compliance and investment into a solution that uses standard APIs that are here to stay.
+
+### New request body methods
+
+You can now read the intercepted request body as you would a regular `Request` instance. This mainly means the addition of the following methods on the `request`:
+
+- `request.blob()`
+- `request.formData()`
+- `request.arrayBuffer()`
+
+For example, this is how you would read the request as `Blob`:
+
+```js
+import { http } from 'msw'
+
+export const handlers = [
+ http.get('/resource', async ({ request }) => {
+ const blob = await request.blob()
+ }),
+]
+```
+
+### Support `ReadableStream` mocked responses
+
+You can now send a `ReadableStream` as the mocked response body. This is great for mocking any kind of streaming in HTTP responses.
+
+```js
+import { http, HttpResponse, delay } from 'msw'
+
+http.get('/greeting', () => {
+ const encoder = new TextEncoder()
+ const stream = new ReadableStream({
+ async start(controller) {
+ controller.enqueue(encoder.encode('hello'))
+ await delay(100)
+ controller.enqueue(encoder.encode('world'))
+ await delay(100)
+ controller.close()
+ },
+ })
+
+ return new HttpResponse(stream)
+})
+```
+
+---
+
+## Common issues
+
+### `Response is not defined`
+
+This likely means that you are running an old version of Node.js. Please use Node.js v18.14.0 and higher with this version of MSW. Also, see [this](#responses-in-nodejs).
+
+### `multipart/form-data is not supported` in Node.js
+
+Earlier versions of Node.js 18, like v18.8.0, had no support for `request.formData()`. Please upgrade to the latest Node.js version where Undici have added the said support to resolve the issue.
diff --git a/src/browser/tsconfig.json b/src/browser/tsconfig.json
new file mode 100644
index 000000000..30d12be0c
--- /dev/null
+++ b/src/browser/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "extends": "../../tsconfig.json",
+ "compilerOptions": {
+ "lib": ["dom", "WebWorker"]
+ },
+ "include": ["./**/*.ts"]
+}
diff --git a/src/core/handlers/HttpHandler.test.ts b/src/core/handlers/HttpHandler.test.ts
index 55f1e0c40..9217e40f8 100644
--- a/src/core/handlers/HttpHandler.test.ts
+++ b/src/core/handlers/HttpHandler.test.ts
@@ -216,3 +216,45 @@ describe('run', () => {
expect(await run()).toBe('complete')
})
})
+
+describe('once', () => {
+ it('marks a matching one-time handler as used', async () => {
+ const handler = new HttpHandler(
+ 'GET',
+ '/resource',
+ () => {
+ return HttpResponse.text('Mocked')
+ },
+ {
+ once: true,
+ },
+ )
+
+ const request = new Request(new URL('/resource', location.href))
+ const result = await handler.run({
+ request,
+ })
+
+ expect(handler.isUsed).toBe(true)
+ expect(result?.handler).toEqual(handler)
+ expect(await result?.response?.text()).toBe('Mocked')
+
+ const resultAfterUsed = await handler.run({
+ request,
+ })
+ expect(handler.isUsed).toBe(true)
+ expect(resultAfterUsed?.handler).toBeUndefined()
+ })
+
+ it('does not mark a non-matching one-time-handler as used', async () => {
+ const handler = new HttpHandler('GET', '/resource', () => undefined, {
+ once: true,
+ })
+
+ const result = await handler.run({
+ request: new Request(new URL('/non-matching', location.href)),
+ })
+
+ expect(result?.handler).toBeUndefined()
+ })
+})
diff --git a/src/core/utils/handleRequest.test.ts b/src/core/utils/handleRequest.test.ts
index 2f49662ea..ff0626edd 100644
--- a/src/core/utils/handleRequest.test.ts
+++ b/src/core/utils/handleRequest.test.ts
@@ -342,3 +342,47 @@ it('returns undefined without warning on a passthrough request', async () => {
expect(callbacks.onPassthroughResponse).toHaveBeenNthCalledWith(1, request)
expect(callbacks.onMockedResponse).not.toHaveBeenCalled()
})
+
+it('marks the first matching one-time handler as used', async () => {
+ const { emitter } = setup()
+
+ const oneTimeHandler = http.get(
+ '/resource',
+ () => {
+ return HttpResponse.text('One-time')
+ },
+ { once: true },
+ )
+ const anotherHandler = http.get('/resource', () => {
+ return HttpResponse.text('Another')
+ })
+ const handlers: Array = [oneTimeHandler, anotherHandler]
+
+ const requestId = uuidv4()
+ const request = new Request('http://localhost/resource')
+ const firstResult = await handleRequest(
+ request,
+ requestId,
+ handlers,
+ options,
+ emitter,
+ callbacks,
+ )
+
+ expect(await firstResult?.text()).toBe('One-time')
+ expect(oneTimeHandler.isUsed).toBe(true)
+ expect(anotherHandler.isUsed).toBe(false)
+
+ const secondResult = await handleRequest(
+ request,
+ requestId,
+ handlers,
+ options,
+ emitter,
+ callbacks,
+ )
+
+ expect(await secondResult?.text()).toBe('Another')
+ expect(anotherHandler.isUsed).toBe(true)
+ expect(oneTimeHandler.isUsed).toBe(true)
+})
diff --git a/src/node/utils/isNodeException.ts b/src/node/utils/isNodeException.ts
new file mode 100644
index 000000000..268e5b8a5
--- /dev/null
+++ b/src/node/utils/isNodeException.ts
@@ -0,0 +1,10 @@
+/**
+ * Determines if the given value is a Node.js exception.
+ * Node.js exceptions have additional information, like
+ * the `code` and `errno` properties.
+ */
+export function isNodeException(
+ error: unknown,
+): error is NodeJS.ErrnoException {
+ return error instanceof Error && 'code' in error
+}
diff --git a/test/modules/node/jest.config.js b/test/modules/node/jest.config.js
new file mode 100644
index 000000000..5d31469cb
--- /dev/null
+++ b/test/modules/node/jest.config.js
@@ -0,0 +1,9 @@
+/** @type {import('jest').Config} */
+module.exports = {
+ rootDir: '.',
+ transform: {
+ '^.+\\.ts$': '@swc/jest',
+ },
+ testEnvironment: 'node',
+ testTimeout: 60_000,
+}