diff --git a/.gitignore b/.gitignore
index 9653b65..d209353 100755
--- a/.gitignore
+++ b/.gitignore
@@ -27,3 +27,6 @@ dist-ssr
docs/plans/**
# tmp
+.serena
+e2e/playwright-report
+e2e/test-results
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 83cfc22..f9ae4c8 100755
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -4,5 +4,9 @@
"editor.codeActionsOnSave": {
"source.fixAll.biome": "explicit"
},
+ "explorer.fileNesting.enabled": true,
+ "explorer.fileNesting.patterns": {
+ "package.json": "package-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock, pnpm-workspace.yaml, skills-lock.json"
+ },
"cSpell.words": []
}
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 1f49e3d..dece445 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,9 +1,7 @@
# CHANGELOG
-All notable changes to this project will be documented in this file.
-
## 0.0.1
-- Initialize the TypeScript library boilerplate.
-- Add Changesets-based release automation.
-- Document npm trusted publishing bootstrap and OIDC-based publishing.
+- Initial release of `@hsblabs/fetch-interceptor`.
+- Add interception support for browser `fetch` and `XMLHttpRequest`.
+- Normalize intercepted traffic to standard `Request` and `Response` objects.
diff --git a/README.md b/README.md
index a929051..47cf3a6 100644
--- a/README.md
+++ b/README.md
@@ -1,119 +1,114 @@
-# @your-scope/your-package
+# @hsblabs/fetch-interceptor
-`@your-scope/your-package` is a boilerplate repository for building and publishing TypeScript libraries to npm.
+Language:
+[English](https://github.com/hsblabs/fetch-interceptor/blob/main/README.md)
+| [日本語](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/ja.md)
+| [简体中文](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/zh-CN.md)
+| [한국어](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/ko.md)
-It includes a strict TypeScript setup, `tsdown` for bundling, Vitest for unit tests, Biome for linting and formatting, and GitHub Actions workflows for CI and tagged publishes.
+`@hsblabs/fetch-interceptor` is a lightweight TypeScript library for transparently intercepting browser `fetch` and `XMLHttpRequest` traffic.
-The exported functions in `src/index.ts` are sample code. Replace them with your library API after you clone the template.
+Its key feature is that intercepted requests and responses are normalized to the standard Web API `Request` and `Response` objects. That lets you inspect and process XHR traffic without dealing with XHR-specific lifecycle complexity.
-The package name in this repository is intentionally a placeholder. This repository is the template, not the final library.
+## Features
-## Included Tooling
+- Unified around standard APIs
+ Both fetch and XHR traffic can be handled through `Request` and `Response`.
+- Fully type-safe
+ The API is predictable and comfortable to use in TypeScript.
+- Safe lifecycle control
+ A factory-based API lets you start interception when needed and restore native browser APIs cleanly.
+- Zero dependencies
+ The library stays small and easy to embed.
+- Built for browser environments
+ The API is intentionally simple for frontend integrations.
-- TypeScript with strict compiler settings
-- `tsdown` for ESM builds and `.d.ts` generation
-- Vitest for unit tests
-- Biome for linting and formatting
-- GitHub Actions for CI and npm publish
-- `prepublishOnly` checks to block broken releases
-
-## Quick Start
+## Installation
```sh
-pnpm install
-pnpm test
-pnpm build
+npm install @hsblabs/fetch-interceptor
+# or
+yarn add @hsblabs/fetch-interceptor
+# or
+pnpm add @hsblabs/fetch-interceptor
```
-To turn this repository into a real package, update these fields first:
-
-- package name, description, keywords, repository URLs in `package.json`
-- README package name and usage examples
-- LICENSE copyright owner if needed
-- sample exports in `src/index.ts`
-- release details in `.changeset/config.json` if your repository conventions differ
-
-## Development Commands
+## Development
```sh
-pnpm dev
-pnpm lint
pnpm test
-pnpm build
-pnpm check
-pnpm changeset
-pnpm version-packages
-pnpm release
+pnpm test:e2e:node
+pnpm test:e2e:browser
```
-## Sample API
+Before running browser E2E tests for the first time, install Playwright Chromium with `pnpm test:e2e:install`.
-The current sample API demonstrates a few pure utilities that are easy to test and replace.
+## Usage
+
+The library is designed to stay small at the call site. The example below intercepts only a specific API route and extracts JSON from the response.
```ts
-import { chunk, clamp, uniqueBy } from "@your-scope/your-package";
-
-const bounded = clamp(14, { min: 0, max: 10 });
-const groups = chunk([1, 2, 3, 4, 5], 2);
-const deduped = uniqueBy(
- [
- { id: "a", group: "x" },
- { id: "b", group: "x" },
- { id: "c", group: "y" },
- ],
- (item) => item.group,
-);
-
-console.log({ bounded, groups, deduped });
+import { createFetchInterceptor } from "@hsblabs/fetch-interceptor";
+
+const interceptor = createFetchInterceptor({
+ matcher: (req) => {
+ const url = new URL(req.url);
+ return url.pathname.includes("/api/target-data") && req.method === "GET";
+ },
+ onIntercept: async (req, res) => {
+ try {
+ const data = await res.json();
+ console.log("Intercepted data:", data);
+
+ // For example, forward data from a Chrome extension's main world
+ // to an isolated world:
+ // window.postMessage({ type: "INTERCEPTED_DATA", payload: data }, "*");
+ } catch (error) {
+ console.error("Failed to parse intercepted response:", error);
+ }
+ },
+});
+
+interceptor.start();
+
+// ...do work...
+
+// interceptor.stop();
```
-## Release Flow
-
-This boilerplate uses Changesets for versioning and release PR management.
+## API Reference
-1. Add a release note with `pnpm changeset`.
-2. Merge the feature PR into `main`.
-3. The `publish.yml` workflow opens or updates a release PR.
-4. Review and merge the release PR.
-5. The same `publish.yml` workflow publishes the package from `main`.
+### `createFetchInterceptor(options: FetchInterceptorOptions): FetchInterceptor`
-## Trusted Publishing Bootstrap
+Creates an interceptor instance used to start and stop traffic interception.
-Because npm lets you attach a Trusted Publisher only after a package exists, there is still a one-time bootstrap step.
+### `FetchInterceptorOptions`
-Recommended operation:
+| Property | Type | Description |
+| --- | --- | --- |
+| `matcher` | `((req: Request) => boolean)?` | Predicate that decides whether a request should be intercepted. When omitted, all matching traffic is intercepted. |
+| `onIntercept` | `(req: Request, res: Response) => void` | Callback invoked when a matching request completes. `res` is a cloned response for fetch, or an equivalent standard `Response` for XHR. |
-1. Publish the package once from your local machine.
-2. Open the package settings on npmjs.com and add a Trusted Publisher.
-3. Set the GitHub repository and the workflow filename to `publish.yml`.
-4. Leave the environment name empty unless you intentionally protect the workflow with a GitHub Environment.
-5. Verify that the next publish works through GitHub Actions.
-6. After verification, disallow token-based publishing in npm package settings and revoke old publish tokens.
+### `FetchInterceptor`
-After bootstrap, later publishes should come from GitHub Actions with OIDC only.
+| Method | Description |
+| --- | --- |
+| `start()` | Overrides `fetch` and `XMLHttpRequest` to begin interception. Calling it more than once is safe. |
+| `stop()` | Stops interception and restores the original browser APIs. |
-## Trusted Publisher Notes
+## Use Cases
-- Trusted publishing works only on GitHub-hosted runners.
-- The publish workflow must keep `id-token: write`.
-- For public packages from public repositories, npm generates provenance automatically when trusted publishing is used.
-- If CI needs private npm dependencies, use a read-only npm token only for install steps, not for `npm publish`.
+- Data extraction in browser extensions
+ Capture underlying REST or GraphQL responses directly from an SPA.
+- Debugging and logging
+ Observe requests and responses for a specific API surface.
+- Test instrumentation
+ Watch network activity and trigger test flow based on real responses.
-## Project Structure
+## Why This Library
-```text
-src/
- index.ts
- index.test.ts
-.github/workflows/
- ci.yml
- publish.yml
-.changeset/
-docs/plans/
-```
+Cross-cutting browser traffic tooling gets messy when `fetch` and XHR need separate handling. `@hsblabs/fetch-interceptor` removes that split so you can focus on monitoring, extraction, debugging, and automation with a single `Request` / `Response` mental model.
-## Notes
+## License
-- The package is ESM-first.
-- Build artifacts are emitted to `dist/`.
-- Only `dist/`, `README.md`, `CHANGELOG.md`, and `LICENSE` are published.
+MIT
diff --git a/docs/README/ja.md b/docs/README/ja.md
new file mode 100644
index 0000000..7968078
--- /dev/null
+++ b/docs/README/ja.md
@@ -0,0 +1,114 @@
+# @hsblabs/fetch-interceptor
+
+言語:
+[English](https://github.com/hsblabs/fetch-interceptor/blob/main/README.md)
+| [日本語](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/ja.md)
+| [简体中文](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/zh-CN.md)
+| [한국어](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/ko.md)
+
+`@hsblabs/fetch-interceptor` は、ブラウザ上の `fetch` と `XMLHttpRequest` を透過的に傍受するための軽量な TypeScript ライブラリです。
+
+最大の特長は、傍受したリクエストとレスポンスを標準 Web API の `Request` と `Response` に正規化して扱えることです。これにより、XHR 固有のライフサイクルの複雑さを意識せずに通信内容を解析できます。
+
+## 特長
+
+- 標準 API に統一
+ fetch と XHR の両方を `Request` / `Response` で扱えます。
+- 完全な型安全
+ TypeScript で予測しやすく扱いやすい API です。
+- 安全なライフサイクル制御
+ 必要なときに傍受を開始し、不要になればネイティブ API をきれいに復元できます。
+- 依存なし
+ 小さく、組み込みやすい構成です。
+- ブラウザ向け設計
+ フロントエンド統合を前提としたシンプルな API です。
+
+## インストール
+
+```sh
+npm install @hsblabs/fetch-interceptor
+# or
+yarn add @hsblabs/fetch-interceptor
+# or
+pnpm add @hsblabs/fetch-interceptor
+```
+
+## 開発
+
+```sh
+pnpm test
+pnpm test:e2e:node
+pnpm test:e2e:browser
+```
+
+ブラウザ E2E テストを初回実行する前に、`pnpm test:e2e:install` で Playwright Chromium をインストールしてください。
+
+## 使い方
+
+呼び出し側は最小限で済むように設計されています。以下の例では、特定の API だけを傍受してレスポンス JSON を取り出します。
+
+```ts
+import { createFetchInterceptor } from "@hsblabs/fetch-interceptor";
+
+const interceptor = createFetchInterceptor({
+ matcher: (req) => {
+ const url = new URL(req.url);
+ return url.pathname.includes("/api/target-data") && req.method === "GET";
+ },
+ onIntercept: async (req, res) => {
+ try {
+ const data = await res.json();
+ console.log("Intercepted data:", data);
+
+ // たとえば Chrome 拡張の main world から isolated world へ
+ // データを転送する場合:
+ // window.postMessage({ type: "INTERCEPTED_DATA", payload: data }, "*");
+ } catch (error) {
+ console.error("Failed to parse intercepted response:", error);
+ }
+ },
+});
+
+interceptor.start();
+
+// ...処理...
+
+// interceptor.stop();
+```
+
+## API リファレンス
+
+### `createFetchInterceptor(options: FetchInterceptorOptions): FetchInterceptor`
+
+通信傍受の開始と停止を制御するインスタンスを生成します。
+
+### `FetchInterceptorOptions`
+
+| プロパティ | 型 | 説明 |
+| --- | --- | --- |
+| `matcher` | `((req: Request) => boolean)?` | リクエストを傍受するかを判定する述語です。省略時はすべての通信を傍受します。 |
+| `onIntercept` | `(req: Request, res: Response) => void` | 条件に一致した通信完了時に呼ばれるコールバックです。`res` は fetch では clone されたレスポンス、XHR では等価な標準 `Response` です。 |
+
+### `FetchInterceptor`
+
+| メソッド | 説明 |
+| --- | --- |
+| `start()` | `fetch` と `XMLHttpRequest` を上書きして傍受を開始します。複数回呼んでも安全です。 |
+| `stop()` | 傍受を停止し、元のブラウザ API を復元します。 |
+
+## ユースケース
+
+- ブラウザ拡張でのデータ抽出
+ SPA が内部で呼ぶ REST や GraphQL のレスポンスを直接取得できます。
+- デバッグとロギング
+ 特定 API のリクエストとレスポンスを観測できます。
+- テスト補助
+ 実際のレスポンスを見ながらテストフローを制御できます。
+
+## このライブラリが解決すること
+
+ブラウザ通信の横断的な監視では、`fetch` と XHR を別々に扱うことが複雑さの原因になります。`@hsblabs/fetch-interceptor` はその分断をなくし、`Request` / `Response` を中心に監視、抽出、デバッグ、自動化へ集中できるようにします。
+
+## ライセンス
+
+MIT
diff --git a/docs/README/ko.md b/docs/README/ko.md
new file mode 100644
index 0000000..3f78036
--- /dev/null
+++ b/docs/README/ko.md
@@ -0,0 +1,114 @@
+# @hsblabs/fetch-interceptor
+
+언어:
+[English](https://github.com/hsblabs/fetch-interceptor/blob/main/README.md)
+| [日本語](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/ja.md)
+| [简体中文](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/zh-CN.md)
+| [한국어](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/ko.md)
+
+`@hsblabs/fetch-interceptor`는 브라우저의 `fetch` 와 `XMLHttpRequest` 트래픽을 투명하게 가로채기 위한 경량 TypeScript 라이브러리입니다.
+
+핵심 특징은 가로챈 요청과 응답을 표준 Web API 인 `Request` 와 `Response` 로 정규화한다는 점입니다. 덕분에 XHR 고유의 라이프사이클 복잡도를 직접 다루지 않고도 네트워크 트래픽을 분석할 수 있습니다.
+
+## 특징
+
+- 표준 API 중심의 통합
+ fetch 와 XHR 모두를 `Request` / `Response` 로 다룰 수 있습니다.
+- 완전한 타입 안정성
+ TypeScript 에서 예측 가능하고 사용하기 쉬운 API 입니다.
+- 안전한 라이프사이클 제어
+ 필요할 때 인터셉션을 시작하고, 끝난 뒤에는 원래 브라우저 API 를 깔끔하게 복원할 수 있습니다.
+- 무의존성
+ 작고 통합하기 쉽습니다.
+- 브라우저 환경에 최적화
+ 프런트엔드 통합을 염두에 둔 단순한 API 입니다.
+
+## 설치
+
+```sh
+npm install @hsblabs/fetch-interceptor
+# or
+yarn add @hsblabs/fetch-interceptor
+# or
+pnpm add @hsblabs/fetch-interceptor
+```
+
+## 개발
+
+```sh
+pnpm test
+pnpm test:e2e:node
+pnpm test:e2e:browser
+```
+
+브라우저 E2E 테스트를 처음 실행하기 전에 `pnpm test:e2e:install` 로 Playwright Chromium 을 설치하세요.
+
+## 사용법
+
+호출부 코드를 최소화하는 것을 목표로 합니다. 아래 예시는 특정 API 경로만 가로채고 응답 JSON 을 추출합니다.
+
+```ts
+import { createFetchInterceptor } from "@hsblabs/fetch-interceptor";
+
+const interceptor = createFetchInterceptor({
+ matcher: (req) => {
+ const url = new URL(req.url);
+ return url.pathname.includes("/api/target-data") && req.method === "GET";
+ },
+ onIntercept: async (req, res) => {
+ try {
+ const data = await res.json();
+ console.log("Intercepted data:", data);
+
+ // 예를 들어 Chrome 확장의 main world 에서
+ // isolated world 로 데이터를 전달할 때:
+ // window.postMessage({ type: "INTERCEPTED_DATA", payload: data }, "*");
+ } catch (error) {
+ console.error("Failed to parse intercepted response:", error);
+ }
+ },
+});
+
+interceptor.start();
+
+// ...작업 수행...
+
+// interceptor.stop();
+```
+
+## API 레퍼런스
+
+### `createFetchInterceptor(options: FetchInterceptorOptions): FetchInterceptor`
+
+네트워크 인터셉션을 시작하고 중지하는 인스턴스를 생성합니다.
+
+### `FetchInterceptorOptions`
+
+| 속성 | 타입 | 설명 |
+| --- | --- | --- |
+| `matcher` | `((req: Request) => boolean)?` | 요청을 가로챌지 결정하는 predicate 입니다. 생략하면 모든 트래픽을 가로챕니다. |
+| `onIntercept` | `(req: Request, res: Response) => void` | 조건에 맞는 요청이 완료되면 호출되는 콜백입니다. `res` 는 fetch 에서는 clone 된 응답이고, XHR 에서는 이에 상응하는 표준 `Response` 입니다. |
+
+### `FetchInterceptor`
+
+| 메서드 | 설명 |
+| --- | --- |
+| `start()` | `fetch` 와 `XMLHttpRequest` 를 override 하여 인터셉션을 시작합니다. 여러 번 호출해도 안전합니다. |
+| `stop()` | 인터셉션을 중지하고 원래 브라우저 API 를 복원합니다. |
+
+## 사용 사례
+
+- 브라우저 확장에서 데이터 추출
+ SPA 내부의 REST 또는 GraphQL 응답을 직접 캡처할 수 있습니다.
+- 디버깅과 로깅
+ 특정 API 표면의 요청과 응답을 관찰할 수 있습니다.
+- 테스트 보조
+ 실제 응답을 기준으로 테스트 흐름을 제어할 수 있습니다.
+
+## 왜 이 라이브러리인가
+
+브라우저 네트워크 트래픽을 횡단적으로 다룰 때 `fetch` 와 XHR 를 따로 처리하면 구현이 쉽게 복잡해집니다. `@hsblabs/fetch-interceptor` 는 그 분리를 없애고, 하나의 `Request` / `Response` 모델로 모니터링, 추출, 디버깅, 자동화에 집중할 수 있게 합니다.
+
+## 라이선스
+
+MIT
diff --git a/docs/README/zh-CN.md b/docs/README/zh-CN.md
new file mode 100644
index 0000000..33cda90
--- /dev/null
+++ b/docs/README/zh-CN.md
@@ -0,0 +1,114 @@
+# @hsblabs/fetch-interceptor
+
+语言:
+[English](https://github.com/hsblabs/fetch-interceptor/blob/main/README.md)
+| [日本語](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/ja.md)
+| [简体中文](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/zh-CN.md)
+| [한국어](https://github.com/hsblabs/fetch-interceptor/blob/main/docs/README/ko.md)
+
+`@hsblabs/fetch-interceptor` 是一个轻量的 TypeScript 库,用于透明地拦截浏览器中的 `fetch` 和 `XMLHttpRequest` 流量。
+
+它的核心特点是将拦截到的请求和响应统一为标准 Web API 的 `Request` 和 `Response` 对象。这样你就可以在不处理 XHR 生命周期细节的情况下分析网络流量。
+
+## 特性
+
+- 基于标准 API 的统一抽象
+ fetch 和 XHR 都可以通过 `Request` / `Response` 处理。
+- 完整的类型安全
+ API 在 TypeScript 中可预测且易于使用。
+- 安全的生命周期控制
+ 你可以在需要时开始拦截,并在结束后干净地恢复原生浏览器 API。
+- 零依赖
+ 体积小,易于集成。
+- 面向浏览器环境
+ API 专为前端集成而设计。
+
+## 安装
+
+```sh
+npm install @hsblabs/fetch-interceptor
+# or
+yarn add @hsblabs/fetch-interceptor
+# or
+pnpm add @hsblabs/fetch-interceptor
+```
+
+## 开发
+
+```sh
+pnpm test
+pnpm test:e2e:node
+pnpm test:e2e:browser
+```
+
+首次运行浏览器 E2E 测试前,请使用 `pnpm test:e2e:install` 安装 Playwright Chromium。
+
+## 用法
+
+这个库的目标是尽量减少调用端样板代码。下面的例子只拦截特定 API 路径,并提取响应中的 JSON。
+
+```ts
+import { createFetchInterceptor } from "@hsblabs/fetch-interceptor";
+
+const interceptor = createFetchInterceptor({
+ matcher: (req) => {
+ const url = new URL(req.url);
+ return url.pathname.includes("/api/target-data") && req.method === "GET";
+ },
+ onIntercept: async (req, res) => {
+ try {
+ const data = await res.json();
+ console.log("Intercepted data:", data);
+
+ // 例如,将数据从 Chrome 扩展的 main world
+ // 转发到 isolated world:
+ // window.postMessage({ type: "INTERCEPTED_DATA", payload: data }, "*");
+ } catch (error) {
+ console.error("Failed to parse intercepted response:", error);
+ }
+ },
+});
+
+interceptor.start();
+
+// ...执行你的逻辑...
+
+// interceptor.stop();
+```
+
+## API 参考
+
+### `createFetchInterceptor(options: FetchInterceptorOptions): FetchInterceptor`
+
+创建一个用于启动和停止网络拦截的实例。
+
+### `FetchInterceptorOptions`
+
+| 属性 | 类型 | 说明 |
+| --- | --- | --- |
+| `matcher` | `((req: Request) => boolean)?` | 用于判断请求是否应被拦截的谓词。省略时将拦截所有流量。 |
+| `onIntercept` | `(req: Request, res: Response) => void` | 当匹配请求完成时调用的回调。`res` 在 fetch 场景中是克隆后的响应,在 XHR 场景中是等价的标准 `Response`。 |
+
+### `FetchInterceptor`
+
+| 方法 | 说明 |
+| --- | --- |
+| `start()` | 覆盖 `fetch` 和 `XMLHttpRequest` 以开始拦截。重复调用也是安全的。 |
+| `stop()` | 停止拦截并恢复原始浏览器 API。 |
+
+## 使用场景
+
+- 浏览器扩展中的数据提取
+ 直接捕获 SPA 内部请求的 REST 或 GraphQL 响应。
+- 调试与日志
+ 观察特定 API 的请求和响应。
+- 测试辅助
+ 基于真实响应推进测试流程。
+
+## 为什么使用这个库
+
+当你需要横切处理浏览器网络流量时,`fetch` 和 XHR 分开处理通常会让实现变得混乱。`@hsblabs/fetch-interceptor` 消除了这种分裂,让你可以围绕统一的 `Request` / `Response` 模型专注于监控、提取、调试和自动化。
+
+## 许可证
+
+MIT
diff --git a/e2e/fixtures/browser/app.js b/e2e/fixtures/browser/app.js
new file mode 100644
index 0000000..bc74f6d
--- /dev/null
+++ b/e2e/fixtures/browser/app.js
@@ -0,0 +1,131 @@
+import { createFetchInterceptor } from "/library.js";
+
+function headersToObject(headers) {
+ return Object.fromEntries(headers.entries());
+}
+
+async function readBody(message) {
+ const text = await message.text();
+ const contentType = message.headers.get("content-type") ?? "";
+
+ if (contentType.includes("application/json") && text) {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+ }
+
+ return text;
+}
+
+async function serializeIntercept(request, response) {
+ return {
+ request: {
+ body: await readBody(request),
+ headers: headersToObject(request.headers),
+ method: request.method,
+ url: request.url,
+ },
+ response: {
+ body: await readBody(response),
+ headers: headersToObject(response.headers),
+ status: response.status,
+ statusText: response.statusText,
+ },
+ };
+}
+
+function createMatcher(useMatcher) {
+ if (!useMatcher) {
+ return undefined;
+ }
+
+ return (request) => new URL(request.url).pathname === "/api/intercepted";
+}
+
+function sendXhr(url, body) {
+ return new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("POST", url);
+ xhr.setRequestHeader("content-type", "application/json");
+ xhr.addEventListener("load", () => resolve());
+ xhr.addEventListener("error", () =>
+ reject(new Error("XHR request failed.")),
+ );
+ xhr.send(JSON.stringify(body));
+ });
+}
+
+async function runFetchScenario({ useMatcher = false } = {}) {
+ const events = [];
+ const pending = [];
+ const interceptor = createFetchInterceptor({
+ matcher: createMatcher(useMatcher),
+ onIntercept: (request, response) => {
+ const task = serializeIntercept(request, response).then((event) => {
+ events.push(event);
+ });
+ pending.push(task);
+ },
+ });
+
+ interceptor.start();
+
+ try {
+ await fetch("/api/intercepted?client=fetch", {
+ body: JSON.stringify({ source: "fetch" }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+
+ if (useMatcher) {
+ await fetch("/api/ignored?client=fetch", {
+ body: JSON.stringify({ source: "ignored" }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ }
+ } finally {
+ interceptor.stop();
+ }
+
+ await Promise.all(pending);
+
+ return events;
+}
+
+async function runXhrScenario({ useMatcher = false } = {}) {
+ const events = [];
+ const pending = [];
+ const interceptor = createFetchInterceptor({
+ matcher: createMatcher(useMatcher),
+ onIntercept: (request, response) => {
+ const task = serializeIntercept(request, response).then((event) => {
+ events.push(event);
+ });
+ pending.push(task);
+ },
+ });
+
+ interceptor.start();
+
+ try {
+ await sendXhr("/api/intercepted?client=xhr", { source: "xhr" });
+
+ if (useMatcher) {
+ await sendXhr("/api/ignored?client=xhr", { source: "ignored" });
+ }
+ } finally {
+ interceptor.stop();
+ }
+
+ await Promise.all(pending);
+
+ return events;
+}
+
+window.e2e = {
+ runFetchScenario,
+ runXhrScenario,
+};
diff --git a/e2e/fixtures/browser/index.html b/e2e/fixtures/browser/index.html
new file mode 100644
index 0000000..5540f23
--- /dev/null
+++ b/e2e/fixtures/browser/index.html
@@ -0,0 +1,12 @@
+
+
+
+
+
+ fetch-interceptor e2e fixture
+
+
+ fetch-interceptor e2e fixture
+
+
+
diff --git a/e2e/package.json b/e2e/package.json
new file mode 100644
index 0000000..f65d1a4
--- /dev/null
+++ b/e2e/package.json
@@ -0,0 +1,26 @@
+{
+ "name": "@hsblabs/fetch-interceptor-e2e",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build:lib": "pnpm --dir .. build",
+ "serve": "pnpm exec tsx ./scripts/start-fixture-server.ts",
+ "install:browsers": "pnpm exec playwright install chromium",
+ "test": "pnpm test:node && pnpm test:browser",
+ "pretest:node": "pnpm build:lib",
+ "test:node": "pnpm exec vitest run --config vitest.config.ts",
+ "pretest:browser": "pnpm build:lib",
+ "test:browser": "pnpm exec playwright test --config playwright.config.ts",
+ "test:browser:headed": "pnpm exec playwright test --config playwright.config.ts --headed"
+ },
+ "engines": {
+ "node": ">=22"
+ },
+ "devDependencies": {
+ "@playwright/test": "^1.50.0",
+ "@types/node": "^25.4.0",
+ "tsx": "^4.19.2",
+ "typescript": "^5.9.3",
+ "vitest": "^4.0.18"
+ }
+}
diff --git a/e2e/playwright.config.ts b/e2e/playwright.config.ts
new file mode 100644
index 0000000..774235f
--- /dev/null
+++ b/e2e/playwright.config.ts
@@ -0,0 +1,17 @@
+import { defineConfig } from "@playwright/test";
+
+export default defineConfig({
+ testDir: "./tests",
+ testMatch: ["**/*.browser.spec.ts"],
+ reporter: "line",
+ use: {
+ baseURL: "http://127.0.0.1:4173",
+ headless: true,
+ },
+ webServer: {
+ command: "pnpm run serve",
+ port: 4173,
+ reuseExistingServer: !process.env.CI,
+ timeout: 30_000,
+ },
+});
diff --git a/e2e/scripts/start-fixture-server.ts b/e2e/scripts/start-fixture-server.ts
new file mode 100644
index 0000000..f6f31e0
--- /dev/null
+++ b/e2e/scripts/start-fixture-server.ts
@@ -0,0 +1,16 @@
+import { startFixtureServer } from "../src/fixture-server";
+
+const port = Number(process.env.PORT ?? "4173");
+const fixtureServer = await startFixtureServer(port);
+
+console.log(`Fixture server listening on ${fixtureServer.origin}`);
+
+const shutdown = async () => {
+ await fixtureServer.close();
+ process.exit(0);
+};
+
+process.on("SIGINT", shutdown);
+process.on("SIGTERM", shutdown);
+
+await new Promise(() => {});
diff --git a/e2e/src/fixture-server.ts b/e2e/src/fixture-server.ts
new file mode 100644
index 0000000..5baecd6
--- /dev/null
+++ b/e2e/src/fixture-server.ts
@@ -0,0 +1,171 @@
+import { readFile } from "node:fs/promises";
+import {
+ createServer,
+ type IncomingMessage,
+ type ServerResponse,
+} from "node:http";
+import { dirname, resolve } from "node:path";
+import { fileURLToPath } from "node:url";
+
+export type FixtureServer = {
+ close: () => Promise;
+ origin: string;
+};
+
+const e2eDirectory = dirname(
+ fileURLToPath(new URL("../package.json", import.meta.url)),
+);
+const repositoryRoot = resolve(e2eDirectory, "..");
+const browserFixturesDirectory = resolve(e2eDirectory, "fixtures/browser");
+const browserIndexPath = resolve(browserFixturesDirectory, "index.html");
+const browserAppPath = resolve(browserFixturesDirectory, "app.js");
+const libraryBundlePath = resolve(repositoryRoot, "dist/index.js");
+
+async function readRequestBody(request: IncomingMessage): Promise {
+ let body = "";
+
+ for await (const chunk of request) {
+ body += chunk.toString();
+ }
+
+ return body;
+}
+
+function parseBody(body: string): unknown {
+ if (!body) {
+ return "";
+ }
+
+ try {
+ return JSON.parse(body);
+ } catch {
+ return body;
+ }
+}
+
+async function writeFileResponse(
+ response: ServerResponse,
+ filePath: string,
+ contentType: string,
+) {
+ const body = await readFile(filePath, "utf8");
+
+ response.statusCode = 200;
+ response.setHeader("cache-control", "no-store");
+ response.setHeader("content-type", contentType);
+ response.end(body);
+}
+
+async function handleApiRequest(
+ request: IncomingMessage,
+ response: ServerResponse,
+ requestUrl: URL,
+) {
+ const requestBody = await readRequestBody(request);
+ const payload = {
+ body: parseBody(requestBody),
+ method: request.method ?? "GET",
+ path: requestUrl.pathname,
+ query: Object.fromEntries(requestUrl.searchParams.entries()),
+ };
+
+ response.statusCode = 200;
+ response.setHeader("cache-control", "no-store");
+ response.setHeader("content-type", "application/json");
+ response.setHeader("x-fixture-source", "e2e-server");
+ response.end(JSON.stringify(payload));
+}
+
+async function handleRequest(
+ request: IncomingMessage,
+ response: ServerResponse,
+) {
+ const requestUrl = new URL(request.url ?? "/", "http://127.0.0.1");
+
+ if (requestUrl.pathname === "/" || requestUrl.pathname === "/index.html") {
+ await writeFileResponse(
+ response,
+ browserIndexPath,
+ "text/html; charset=utf-8",
+ );
+ return;
+ }
+
+ if (requestUrl.pathname === "/app.js") {
+ await writeFileResponse(
+ response,
+ browserAppPath,
+ "text/javascript; charset=utf-8",
+ );
+ return;
+ }
+
+ if (requestUrl.pathname === "/library.js") {
+ await writeFileResponse(
+ response,
+ libraryBundlePath,
+ "text/javascript; charset=utf-8",
+ );
+ return;
+ }
+
+ if (requestUrl.pathname.startsWith("/api/")) {
+ await handleApiRequest(request, response, requestUrl);
+ return;
+ }
+
+ if (requestUrl.pathname === "/favicon.ico") {
+ response.statusCode = 204;
+ response.end();
+ return;
+ }
+
+ response.statusCode = 404;
+ response.setHeader("content-type", "text/plain; charset=utf-8");
+ response.end("Not found");
+}
+
+export async function startFixtureServer(port = 0): Promise {
+ const server = createServer(async (request, response) => {
+ try {
+ await handleRequest(request, response);
+ } catch (error) {
+ response.statusCode = 500;
+ response.setHeader("content-type", "application/json; charset=utf-8");
+ response.end(
+ JSON.stringify({
+ message: error instanceof Error ? error.message : String(error),
+ }),
+ );
+ }
+ });
+
+ await new Promise((resolvePromise, rejectPromise) => {
+ server.once("error", rejectPromise);
+ server.listen(port, "127.0.0.1", () => {
+ server.off("error", rejectPromise);
+ resolvePromise();
+ });
+ });
+
+ const address = server.address();
+
+ if (!address || typeof address === "string") {
+ throw new Error("Fixture server failed to bind to a TCP port.");
+ }
+
+ return {
+ close: () =>
+ new Promise((resolvePromise, rejectPromise) => {
+ server.close((error) => {
+ if (error) {
+ rejectPromise(error);
+ return;
+ }
+
+ resolvePromise();
+ });
+ }),
+ origin: `http://127.0.0.1:${address.port}`,
+ };
+}
diff --git a/e2e/tests/browser.browser.spec.ts b/e2e/tests/browser.browser.spec.ts
new file mode 100644
index 0000000..22cd05b
--- /dev/null
+++ b/e2e/tests/browser.browser.spec.ts
@@ -0,0 +1,70 @@
+import { expect, test } from "@playwright/test";
+
+type BrowserIntercept = {
+ request: {
+ body: unknown;
+ headers: Record;
+ method: string;
+ url: string;
+ };
+ response: {
+ body: unknown;
+ headers: Record;
+ status: number;
+ statusText: string;
+ };
+};
+
+declare global {
+ interface Window {
+ e2e: {
+ runFetchScenario: (options?: {
+ useMatcher?: boolean;
+ }) => Promise;
+ runXhrScenario: (options?: {
+ useMatcher?: boolean;
+ }) => Promise;
+ };
+ }
+}
+
+test("intercepts real browser fetch traffic", async ({ page }) => {
+ await page.goto("/", { waitUntil: "networkidle" });
+
+ const events = await page.evaluate(() => window.e2e.runFetchScenario());
+
+ expect(events).toHaveLength(1);
+ expect(events[0]?.request.method).toBe("POST");
+ expect(events[0]?.request.body).toEqual({ source: "fetch" });
+ expect(events[0]?.request.url).toContain("/api/intercepted?client=fetch");
+ expect(events[0]?.response.status).toBe(200);
+ expect(events[0]?.response.headers["x-fixture-source"]).toBe("e2e-server");
+ expect(events[0]?.response.body).toMatchObject({
+ body: { source: "fetch" },
+ method: "POST",
+ path: "/api/intercepted",
+ query: { client: "fetch" },
+ });
+});
+
+test("intercepts real browser xhr traffic and respects matcher filters", async ({
+ page,
+}) => {
+ await page.goto("/", { waitUntil: "networkidle" });
+
+ const events = await page.evaluate(() =>
+ window.e2e.runXhrScenario({ useMatcher: true }),
+ );
+
+ expect(events).toHaveLength(1);
+ expect(events[0]?.request.method).toBe("POST");
+ expect(events[0]?.request.body).toEqual({ source: "xhr" });
+ expect(events[0]?.request.url).toContain("/api/intercepted?client=xhr");
+ expect(events[0]?.response.status).toBe(200);
+ expect(events[0]?.response.body).toMatchObject({
+ body: { source: "xhr" },
+ method: "POST",
+ path: "/api/intercepted",
+ query: { client: "xhr" },
+ });
+});
diff --git a/e2e/tests/node.node.test.ts b/e2e/tests/node.node.test.ts
new file mode 100644
index 0000000..6cb78ce
--- /dev/null
+++ b/e2e/tests/node.node.test.ts
@@ -0,0 +1,166 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { type FixtureServer, startFixtureServer } from "../src/fixture-server";
+
+type InterceptSnapshot = {
+ request: {
+ body: unknown;
+ headers: Record;
+ method: string;
+ url: string;
+ };
+ response: {
+ body: unknown;
+ headers: Record;
+ status: number;
+ statusText: string;
+ };
+};
+
+type CreateFetchInterceptor =
+ typeof import("../../src/index")["createFetchInterceptor"];
+
+function headersToObject(headers: Headers): Record {
+ return Object.fromEntries(headers.entries());
+}
+
+async function readBody(message: Request | Response): Promise {
+ const text = await message.text();
+ const contentType = message.headers.get("content-type") ?? "";
+
+ if (contentType.includes("application/json") && text) {
+ try {
+ return JSON.parse(text);
+ } catch {
+ return text;
+ }
+ }
+
+ return text;
+}
+
+async function serializeIntercept(
+ request: Request,
+ response: Response,
+): Promise {
+ return {
+ request: {
+ body: await readBody(request),
+ headers: headersToObject(request.headers),
+ method: request.method,
+ url: request.url,
+ },
+ response: {
+ body: await readBody(response),
+ headers: headersToObject(response.headers),
+ status: response.status,
+ statusText: response.statusText,
+ },
+ };
+}
+
+async function loadCreateFetchInterceptor(): Promise {
+ const libraryUrl = new URL("../../dist/index.js", import.meta.url);
+ const libraryModule = (await import(libraryUrl.href)) as {
+ createFetchInterceptor: CreateFetchInterceptor;
+ };
+
+ return libraryModule.createFetchInterceptor;
+}
+
+describe("real network behavior in Node", () => {
+ let fixtureServer: FixtureServer;
+ let createFetchInterceptor: CreateFetchInterceptor;
+
+ beforeAll(async () => {
+ fixtureServer = await startFixtureServer();
+ createFetchInterceptor = await loadCreateFetchInterceptor();
+ });
+
+ afterAll(async () => {
+ await fixtureServer.close();
+ });
+
+ it("intercepts real fetch traffic with the default matcher", async () => {
+ const intercepted: InterceptSnapshot[] = [];
+ const pending: Array> = [];
+ const interceptor = createFetchInterceptor({
+ onIntercept: (request, response) => {
+ const task = serializeIntercept(request, response).then((event) => {
+ intercepted.push(event);
+ });
+ pending.push(task);
+ },
+ });
+
+ interceptor.start();
+
+ try {
+ await fetch(`${fixtureServer.origin}/api/intercepted?client=node`, {
+ body: JSON.stringify({ source: "node" }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ } finally {
+ interceptor.stop();
+ }
+
+ await Promise.all(pending);
+
+ expect(intercepted).toHaveLength(1);
+ expect(intercepted[0]?.request.body).toEqual({ source: "node" });
+ expect(intercepted[0]?.request.url).toContain(
+ "/api/intercepted?client=node",
+ );
+ expect(intercepted[0]?.response.status).toBe(200);
+ expect(intercepted[0]?.response.body).toMatchObject({
+ body: { source: "node" },
+ method: "POST",
+ path: "/api/intercepted",
+ query: { client: "node" },
+ });
+ });
+
+ it("respects matcher filters against real fetch traffic", async () => {
+ const intercepted: InterceptSnapshot[] = [];
+ const pending: Array> = [];
+ const interceptor = createFetchInterceptor({
+ matcher: (request) =>
+ new URL(request.url).pathname === "/api/intercepted",
+ onIntercept: (request, response) => {
+ const task = serializeIntercept(request, response).then((event) => {
+ intercepted.push(event);
+ });
+ pending.push(task);
+ },
+ });
+
+ interceptor.start();
+
+ try {
+ await fetch(`${fixtureServer.origin}/api/intercepted?client=node`, {
+ body: JSON.stringify({ source: "matched" }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ await fetch(`${fixtureServer.origin}/api/ignored?client=node`, {
+ body: JSON.stringify({ source: "ignored" }),
+ headers: { "content-type": "application/json" },
+ method: "POST",
+ });
+ } finally {
+ interceptor.stop();
+ }
+
+ await Promise.all(pending);
+
+ expect(intercepted).toHaveLength(1);
+ expect(intercepted[0]?.request.url).toContain(
+ "/api/intercepted?client=node",
+ );
+ expect(intercepted[0]?.response.body).toMatchObject({
+ body: { source: "matched" },
+ path: "/api/intercepted",
+ });
+ });
+});
diff --git a/e2e/tsconfig.json b/e2e/tsconfig.json
new file mode 100644
index 0000000..580b5ca
--- /dev/null
+++ b/e2e/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "strict": true,
+ "esModuleInterop": true,
+ "forceConsistentCasingInFileNames": true,
+ "skipLibCheck": true,
+ "types": ["node", "@playwright/test"]
+ },
+ "include": ["scripts/**/*.ts", "src/**/*.ts", "tests/**/*.ts"]
+}
diff --git a/e2e/vitest.config.ts b/e2e/vitest.config.ts
new file mode 100644
index 0000000..d339a07
--- /dev/null
+++ b/e2e/vitest.config.ts
@@ -0,0 +1,8 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["tests/**/*.node.test.ts"],
+ },
+});
diff --git a/package.json b/package.json
index 7f3f2f1..27e3c20 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
- "name": "@your-scope/your-package",
+ "name": "@hsblabs/fetch-interceptor",
"version": "0.0.1",
- "description": "A production-ready boilerplate for npm-publishable TypeScript libraries.",
+ "description": "Lightweight TypeScript library for intercepting browser fetch and XMLHttpRequest traffic.",
"type": "module",
"types": "./dist/index.d.ts",
"scripts": {
@@ -14,6 +14,10 @@
"prepublishOnly": "pnpm check",
"test": "vitest run",
"test:watch": "vitest",
+ "test:e2e": "pnpm --filter @hsblabs/fetch-interceptor-e2e test",
+ "test:e2e:node": "pnpm --filter @hsblabs/fetch-interceptor-e2e test:node",
+ "test:e2e:browser": "pnpm --filter @hsblabs/fetch-interceptor-e2e test:browser",
+ "test:e2e:install": "pnpm --filter @hsblabs/fetch-interceptor-e2e install:browsers",
"lint": "biome check .",
"lint:fix": "biome check --write .",
"format": "biome format --write ."
@@ -30,20 +34,23 @@
"registry": "https://registry.npmjs.org/"
},
"keywords": [
+ "fetch",
+ "interceptor",
+ "network",
+ "browser",
"typescript",
"library",
- "npm",
- "boilerplate",
- "template",
+ "xhr",
+ "xmlhttprequest",
"esm"
],
"repository": {
"type": "git",
- "url": "git+https://github.com/hsblabs/typescript-library.git"
+ "url": "git+https://github.com/hsblabs/fetch-interceptor.git"
},
- "homepage": "https://github.com/hsblabs/typescript-library#readme",
+ "homepage": "https://github.com/hsblabs/fetch-interceptor#readme",
"bugs": {
- "url": "https://github.com/hsblabs/typescript-library/issues"
+ "url": "https://github.com/hsblabs/fetch-interceptor/issues"
},
"license": "MIT",
"engines": {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 82870f6..80685fe 100755
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -25,7 +25,25 @@ importers:
version: 5.9.3
vitest:
specifier: ^4.0.18
- version: 4.0.18(@types/node@25.4.0)(yaml@2.4.2)
+ version: 4.0.18(@types/node@25.4.0)(tsx@4.21.0)(yaml@2.4.2)
+
+ e2e:
+ devDependencies:
+ '@playwright/test':
+ specifier: ^1.50.0
+ version: 1.58.2
+ '@types/node':
+ specifier: ^25.4.0
+ version: 25.4.0
+ tsx:
+ specifier: ^4.19.2
+ version: 4.21.0
+ typescript:
+ specifier: ^5.9.3
+ version: 5.9.3
+ vitest:
+ specifier: ^4.0.18
+ version: 4.0.18(@types/node@25.4.0)(tsx@4.21.0)(yaml@2.4.2)
packages:
@@ -377,6 +395,11 @@ packages:
'@oxc-project/types@0.115.0':
resolution: {integrity: sha512-4n91DKnebUS4yjUHl2g3/b2T+IUdCfmoZGhmwsovZCDaJSs+QkVAM+0AqqTxHSsHfeiMuueT75cZaZcT/m0pSw==}
+ '@playwright/test@1.58.2':
+ resolution: {integrity: sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@quansync/fs@1.0.0':
resolution: {integrity: sha512-4TJ3DFtlf1L5LDMaM6CanJ/0lckGNtJcMjQ1NAV6zDmA0tEHKZtxNKin8EgPaVX1YzljbxckyT2tJrpQKAtngQ==}
@@ -808,6 +831,11 @@ packages:
resolution: {integrity: sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g==}
engines: {node: '>=6 <7 || >=8'}
+ fsevents@2.3.2:
+ resolution: {integrity: sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==}
+ engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
+ os: [darwin]
+
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@@ -971,6 +999,16 @@ packages:
resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==}
engines: {node: '>=6'}
+ playwright-core@1.58.2:
+ resolution: {integrity: sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==}
+ engines: {node: '>=18'}
+ hasBin: true
+
+ playwright@1.58.2:
+ resolution: {integrity: sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==}
+ engines: {node: '>=18'}
+ hasBin: true
+
postcss@8.5.6:
resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==}
engines: {node: ^10 || ^12 || >=14}
@@ -1145,6 +1183,11 @@ packages:
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
+ tsx@4.21.0:
+ resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==}
+ engines: {node: '>=18.0.0'}
+ hasBin: true
+
typescript@5.9.3:
resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==}
engines: {node: '>=14.17'}
@@ -1615,6 +1658,10 @@ snapshots:
'@oxc-project/types@0.115.0': {}
+ '@playwright/test@1.58.2':
+ dependencies:
+ playwright: 1.58.2
+
'@quansync/fs@1.0.0':
dependencies:
quansync: 1.0.0
@@ -1776,13 +1823,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.0.3
- '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.4.0)(yaml@2.4.2))':
+ '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@25.4.0)(tsx@4.21.0)(yaml@2.4.2))':
dependencies:
'@vitest/spy': 4.0.18
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 7.3.1(@types/node@25.4.0)(yaml@2.4.2)
+ vite: 7.3.1(@types/node@25.4.0)(tsx@4.21.0)(yaml@2.4.2)
'@vitest/pretty-format@4.0.18':
dependencies:
@@ -1945,6 +1992,9 @@ snapshots:
jsonfile: 4.0.0
universalify: 0.1.2
+ fsevents@2.3.2:
+ optional: true
+
fsevents@2.3.3:
optional: true
@@ -2071,6 +2121,14 @@ snapshots:
pify@4.0.1: {}
+ playwright-core@1.58.2: {}
+
+ playwright@1.58.2:
+ dependencies:
+ playwright-core: 1.58.2
+ optionalDependencies:
+ fsevents: 2.3.2
+
postcss@8.5.6:
dependencies:
nanoid: 3.3.11
@@ -2255,6 +2313,13 @@ snapshots:
tslib@2.8.1:
optional: true
+ tsx@4.21.0:
+ dependencies:
+ esbuild: 0.27.3
+ get-tsconfig: 4.13.6
+ optionalDependencies:
+ fsevents: 2.3.3
+
typescript@5.9.3: {}
unconfig-core@7.5.0:
@@ -2270,7 +2335,7 @@ snapshots:
dependencies:
rolldown: 1.0.0-rc.9
- vite@7.3.1(@types/node@25.4.0)(yaml@2.4.2):
+ vite@7.3.1(@types/node@25.4.0)(tsx@4.21.0)(yaml@2.4.2):
dependencies:
esbuild: 0.27.3
fdir: 6.5.0(picomatch@4.0.3)
@@ -2281,12 +2346,13 @@ snapshots:
optionalDependencies:
'@types/node': 25.4.0
fsevents: 2.3.3
+ tsx: 4.21.0
yaml: 2.4.2
- vitest@4.0.18(@types/node@25.4.0)(yaml@2.4.2):
+ vitest@4.0.18(@types/node@25.4.0)(tsx@4.21.0)(yaml@2.4.2):
dependencies:
'@vitest/expect': 4.0.18
- '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.4.0)(yaml@2.4.2))
+ '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@25.4.0)(tsx@4.21.0)(yaml@2.4.2))
'@vitest/pretty-format': 4.0.18
'@vitest/runner': 4.0.18
'@vitest/snapshot': 4.0.18
@@ -2303,7 +2369,7 @@ snapshots:
tinyexec: 1.0.2
tinyglobby: 0.2.15
tinyrainbow: 3.0.3
- vite: 7.3.1(@types/node@25.4.0)(yaml@2.4.2)
+ vite: 7.3.1(@types/node@25.4.0)(tsx@4.21.0)(yaml@2.4.2)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.4.0
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
index c5739b7..21f1d6b 100644
--- a/pnpm-workspace.yaml
+++ b/pnpm-workspace.yaml
@@ -1,2 +1,5 @@
+packages:
+ - e2e
+
ignoredBuiltDependencies:
- esbuild
diff --git a/src/fetch.test.ts b/src/fetch.test.ts
new file mode 100644
index 0000000..0b8b619
--- /dev/null
+++ b/src/fetch.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it, vi } from "vitest";
+
+import { createFetchInterceptorHandler, createFetchRequest } from "./fetch";
+
+describe("createFetchRequest", () => {
+ it("clones Request inputs", async () => {
+ const originalRequest = new Request("https://example.com/clone", {
+ body: "hello",
+ method: "POST",
+ });
+
+ const clonedRequest = createFetchRequest(originalRequest);
+
+ await originalRequest.text();
+
+ expect(clonedRequest).not.toBe(originalRequest);
+ expect(await clonedRequest.text()).toBe("hello");
+ });
+
+ it("creates a Request from fetch arguments", async () => {
+ const request = createFetchRequest("https://example.com/from-args", {
+ body: JSON.stringify({ hello: "world" }),
+ headers: { "content-type": "application/json" },
+ method: "PUT",
+ });
+
+ expect(request.method).toBe("PUT");
+ expect(request.headers.get("content-type")).toBe("application/json");
+ expect(await request.json()).toEqual({ hello: "world" });
+ });
+});
+
+describe("createFetchInterceptorHandler", () => {
+ it("intercepts matching requests with a cloned response", async () => {
+ const onIntercept = vi.fn();
+ const originalFetch = vi.fn(async () => {
+ return new Response(JSON.stringify({ ok: true }), {
+ headers: { "content-type": "application/json" },
+ });
+ });
+ const interceptedFetch = createFetchInterceptorHandler(originalFetch, {
+ matcher: (request) => request.url === "https://example.com/target",
+ onIntercept,
+ });
+
+ const response = await interceptedFetch("https://example.com/target");
+
+ expect(originalFetch).toHaveBeenCalledOnce();
+ expect(onIntercept).toHaveBeenCalledOnce();
+
+ const [request, interceptedResponse] = onIntercept.mock.calls[0];
+
+ expect(request.url).toBe("https://example.com/target");
+ expect(interceptedResponse).not.toBe(response);
+ expect(await interceptedResponse.json()).toEqual({ ok: true });
+ expect(await response.json()).toEqual({ ok: true });
+ });
+
+ it("skips onIntercept when matcher returns false", async () => {
+ const onIntercept = vi.fn();
+ const originalFetch = vi.fn(async () => new Response("ok"));
+ const interceptedFetch = createFetchInterceptorHandler(originalFetch, {
+ matcher: () => false,
+ onIntercept,
+ });
+
+ await interceptedFetch("https://example.com/ignored");
+
+ expect(originalFetch).toHaveBeenCalledOnce();
+ expect(onIntercept).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/fetch.ts b/src/fetch.ts
new file mode 100644
index 0000000..b47df0d
--- /dev/null
+++ b/src/fetch.ts
@@ -0,0 +1,49 @@
+import type { FetchInterceptorOptions } from "./types";
+
+type RuntimeInterceptorOptions = Omit & {
+ matcher: NonNullable;
+};
+
+export function createFetchRequest(
+ ...args: Parameters
+): Request {
+ const [input, init] = args;
+
+ if (input instanceof Request) {
+ return input.clone();
+ }
+
+ return new Request(input, init);
+}
+
+export function createFetchInterceptorHandler(
+ originalFetch: typeof globalThis.fetch,
+ options: RuntimeInterceptorOptions,
+): typeof globalThis.fetch {
+ return async function interceptedFetch(
+ ...args: Parameters
+ ) {
+ const request = createFetchRequest(...args);
+ const response = await originalFetch(...args);
+
+ if (options.matcher(request)) {
+ options.onIntercept(request, response.clone());
+ }
+
+ return response;
+ };
+}
+
+/**
+ * Intercepts globalThis.fetch and returns a restore function.
+ */
+export function interceptFetch(options: RuntimeInterceptorOptions): () => void {
+ const originalFetch = globalThis.fetch;
+
+ globalThis.fetch = createFetchInterceptorHandler(originalFetch, options);
+
+ // Return a cleanup function.
+ return function restoreFetch() {
+ globalThis.fetch = originalFetch;
+ };
+}
diff --git a/src/index.test.ts b/src/index.test.ts
index a866f8b..12ba989 100644
--- a/src/index.test.ts
+++ b/src/index.test.ts
@@ -1,42 +1,259 @@
-import { describe, expect, it } from "vitest";
+import {
+ afterAll,
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
-import { chunk, clamp, uniqueBy } from "./index";
+import { createFetchInterceptor } from "./index";
-describe("clamp", () => {
- it("returns the lower bound when the value is below the range", () => {
- expect(clamp(-5, { min: 0, max: 10 })).toBe(0);
+type Deferred = {
+ promise: Promise;
+ resolve: (value: T) => void;
+};
+
+type MockXhrResponse = {
+ body?: BodyInit | null;
+ headers?: Record;
+ response?: XMLHttpRequest["response"];
+ responseText?: string;
+ status?: number;
+ statusText?: string;
+};
+
+function createDeferred(): Deferred {
+ let resolve!: (value: T) => void;
+ const promise = new Promise((res) => {
+ resolve = res;
});
- it("returns the upper bound when the value is above the range", () => {
- expect(clamp(20, { min: 0, max: 10 })).toBe(10);
+ return { promise, resolve };
+}
+
+class MockXMLHttpRequest {
+ static responses: MockXhrResponse[] = [];
+
+ static reset() {
+ MockXMLHttpRequest.responses = [];
+ }
+
+ static enqueueResponse(response: MockXhrResponse) {
+ MockXMLHttpRequest.responses.push(response);
+ }
+
+ private listeners = new Map void>>();
+ private rawResponseHeaders = "";
+
+ status = 0;
+ statusText = "";
+ response: XMLHttpRequest["response"] = null;
+ responseText = "";
+
+ open(_method: string, _url: string | URL, ..._args: unknown[]) {}
+
+ setRequestHeader(_name: string, _value: string) {}
+
+ addEventListener(type: string, listener: () => void) {
+ const callbacks = this.listeners.get(type) ?? [];
+ callbacks.push(listener);
+ this.listeners.set(type, callbacks);
+ }
+
+ getAllResponseHeaders() {
+ return this.rawResponseHeaders;
+ }
+
+ send(_body?: Document | XMLHttpRequestBodyInit | null) {
+ const nextResponse = MockXMLHttpRequest.responses.shift() ?? {};
+
+ this.status = nextResponse.status ?? 200;
+ this.statusText = nextResponse.statusText ?? "OK";
+ this.response = nextResponse.response ?? nextResponse.body ?? null;
+ this.responseText =
+ nextResponse.responseText ??
+ (typeof nextResponse.body === "string" ? nextResponse.body : "");
+ this.rawResponseHeaders = Object.entries(nextResponse.headers ?? {})
+ .map(([name, value]) => `${name}: ${value}`)
+ .join("\r\n");
+
+ for (const listener of this.listeners.get("load") ?? []) {
+ listener();
+ }
+ }
+}
+
+const nativeFetch = globalThis.fetch;
+const nativeXmlHttpRequest = globalThis.XMLHttpRequest;
+
+function useMockXmlHttpRequest() {
+ globalThis.XMLHttpRequest =
+ MockXMLHttpRequest as unknown as typeof XMLHttpRequest;
+}
+
+function restoreBrowserGlobals() {
+ globalThis.fetch = nativeFetch;
+
+ if (nativeXmlHttpRequest) {
+ globalThis.XMLHttpRequest = nativeXmlHttpRequest;
+ return;
+ }
+
+ Reflect.deleteProperty(globalThis, "XMLHttpRequest");
+}
+
+describe("createFetchInterceptor", () => {
+ beforeEach(() => {
+ vi.restoreAllMocks();
+ MockXMLHttpRequest.reset();
+ restoreBrowserGlobals();
});
- it("throws when the range is invalid", () => {
- expect(() => clamp(5, { min: 10, max: 0 })).toThrow(RangeError);
+ afterEach(() => {
+ restoreBrowserGlobals();
});
-});
-describe("chunk", () => {
- it("splits an array into evenly sized chunks", () => {
- expect(chunk([1, 2, 3, 4, 5], 2)).toEqual([[1, 2], [3, 4], [5]]);
+ afterAll(() => {
+ restoreBrowserGlobals();
});
- it("throws when the chunk size is not a positive integer", () => {
- expect(() => chunk([1, 2, 3], 0)).toThrow(RangeError);
+ it("defaults matcher to true for fetch requests", async () => {
+ const intercepted = createDeferred<{
+ request: Request;
+ response: Response;
+ }>();
+ const originalFetch = vi.fn(async () => {
+ return new Response(JSON.stringify({ ok: true }), {
+ headers: { "content-type": "application/json" },
+ status: 201,
+ });
+ });
+
+ globalThis.fetch = originalFetch as unknown as typeof fetch;
+ useMockXmlHttpRequest();
+
+ const interceptor = createFetchInterceptor({
+ onIntercept: (request, response) =>
+ intercepted.resolve({ request, response }),
+ });
+
+ interceptor.start();
+
+ const response = await fetch("https://example.com/target", {
+ body: JSON.stringify({ hello: "world" }),
+ method: "POST",
+ });
+ const { request, response: interceptedResponse } =
+ await intercepted.promise;
+
+ expect(originalFetch).toHaveBeenCalledOnce();
+ expect(await request.json()).toEqual({ hello: "world" });
+ expect(interceptedResponse).not.toBe(response);
+ expect(await interceptedResponse.json()).toEqual({ ok: true });
+ expect(await response.json()).toEqual({ ok: true });
});
-});
-describe("uniqueBy", () => {
- it("keeps the first item for each computed key", () => {
- const items = [
- { id: "a", value: 1 },
- { id: "b", value: 1 },
- { id: "c", value: 2 },
- ];
-
- expect(uniqueBy(items, (item) => item.value)).toEqual([
- { id: "a", value: 1 },
- { id: "c", value: 2 },
- ]);
+ it("skips fetch callbacks when the matcher returns false", async () => {
+ const onIntercept = vi.fn();
+ const originalFetch = vi.fn(async () => new Response("ok"));
+
+ globalThis.fetch = originalFetch as unknown as typeof fetch;
+ useMockXmlHttpRequest();
+
+ const interceptor = createFetchInterceptor({
+ matcher: () => false,
+ onIntercept,
+ });
+
+ interceptor.start();
+ await fetch("https://example.com/ignored");
+
+ expect(originalFetch).toHaveBeenCalledOnce();
+ expect(onIntercept).not.toHaveBeenCalled();
+ });
+
+ it("defaults matcher to true for XMLHttpRequest calls", async () => {
+ const intercepted = createDeferred<{
+ request: Request;
+ response: Response;
+ }>();
+
+ useMockXmlHttpRequest();
+ MockXMLHttpRequest.enqueueResponse({
+ body: JSON.stringify({ ok: true }),
+ headers: {
+ "content-type": "application/json",
+ "x-request-id": "req-1",
+ },
+ status: 202,
+ statusText: "Accepted",
+ });
+
+ const interceptor = createFetchInterceptor({
+ onIntercept: (request, response) =>
+ intercepted.resolve({ request, response }),
+ });
+
+ interceptor.start();
+
+ const xhr = new XMLHttpRequest();
+ xhr.open("post", "https://example.com/xhr");
+ xhr.setRequestHeader("x-token", "secret");
+ xhr.send(JSON.stringify({ hello: "world" }));
+
+ const { request, response } = await intercepted.promise;
+
+ expect(request.method).toBe("POST");
+ expect(request.headers.get("x-token")).toBe("secret");
+ expect(await request.json()).toEqual({ hello: "world" });
+ expect(response.status).toBe(202);
+ expect(response.statusText).toBe("Accepted");
+ expect(response.headers.get("x-request-id")).toBe("req-1");
+ expect(await response.json()).toEqual({ ok: true });
+ });
+
+ it("starts only once and restores the original globals on stop", async () => {
+ const onIntercept = vi.fn();
+ const originalFetch = vi.fn(async () => new Response("ok"));
+
+ globalThis.fetch = originalFetch as unknown as typeof fetch;
+ useMockXmlHttpRequest();
+
+ const originalXhrOpen = XMLHttpRequest.prototype.open;
+ const originalXhrSend = XMLHttpRequest.prototype.send;
+ const originalXhrSetRequestHeader =
+ XMLHttpRequest.prototype.setRequestHeader;
+ const interceptor = createFetchInterceptor({
+ matcher: () => true,
+ onIntercept,
+ });
+
+ interceptor.start();
+
+ const patchedFetch = globalThis.fetch;
+ const patchedXhrOpen = XMLHttpRequest.prototype.open;
+
+ interceptor.start();
+ await fetch("https://example.com/lifecycle");
+
+ expect(globalThis.fetch).toBe(patchedFetch);
+ expect(XMLHttpRequest.prototype.open).toBe(patchedXhrOpen);
+ expect(onIntercept).toHaveBeenCalledTimes(1);
+
+ interceptor.stop();
+
+ expect(globalThis.fetch).toBe(originalFetch);
+ expect(XMLHttpRequest.prototype.open).toBe(originalXhrOpen);
+ expect(XMLHttpRequest.prototype.send).toBe(originalXhrSend);
+ expect(XMLHttpRequest.prototype.setRequestHeader).toBe(
+ originalXhrSetRequestHeader,
+ );
+ expect(() => interceptor.stop()).not.toThrow();
+
+ await fetch("https://example.com/after-stop");
+
+ expect(onIntercept).toHaveBeenCalledTimes(1);
});
});
diff --git a/src/index.ts b/src/index.ts
index 6857e2a..354c214 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,47 +1,50 @@
-export interface ClampRange {
- min: number;
- max: number;
+import { interceptFetch } from "./fetch";
+import type { FetchInterceptor, FetchInterceptorOptions } from "./types";
+import { interceptXhr } from "./xhr";
+
+export * from "./types";
+
+const matchAllRequests = () => true;
+
+/**
+ * Creates a FetchInterceptor instance that intercepts network traffic.
+ * @param options Matcher and callback options for interception.
+ * @returns A control interface with start and stop methods.
+ */
+export function createFetchInterceptor(
+ options: FetchInterceptorOptions,
+): FetchInterceptor {
+ let isRunning = false;
+ const resolvedOptions = {
+ ...options,
+ matcher: options.matcher ?? matchAllRequests,
+ };
+
+ // Hold the cleanup functions returned by each interception module.
+ let restoreFetch: (() => void) | null = null;
+ let restoreXhr: (() => void) | null = null;
+
+ const start = () => {
+ if (isRunning) return;
+ isRunning = true;
+
+ // Start fetch and xhr interception and keep their restore handlers.
+ restoreFetch = interceptFetch(resolvedOptions);
+ restoreXhr = interceptXhr(resolvedOptions);
+ };
+
+ const stop = () => {
+ if (!isRunning) return;
+ isRunning = false;
+
+ // Run the stored restore handlers to put globals back in place.
+ if (restoreFetch) restoreFetch();
+ if (restoreXhr) restoreXhr();
+
+ // Clear the stored references.
+ restoreFetch = null;
+ restoreXhr = null;
+ };
+
+ return { start, stop };
}
-
-export const clamp = (value: number, range: ClampRange): number => {
- if (range.min > range.max) {
- throw new RangeError("range.min must be less than or equal to range.max");
- }
-
- return Math.min(Math.max(value, range.min), range.max);
-};
-
-export const chunk = (items: readonly T[], size: number): T[][] => {
- if (!Number.isInteger(size) || size < 1) {
- throw new RangeError("size must be a positive integer");
- }
-
- const result: T[][] = [];
-
- for (let index = 0; index < items.length; index += size) {
- result.push(items.slice(index, index + size));
- }
-
- return result;
-};
-
-export const uniqueBy = (
- items: readonly T[],
- getKey: (item: T) => Key,
-): T[] => {
- const seen = new Set();
- const result: T[] = [];
-
- for (const item of items) {
- const key = getKey(item);
-
- if (seen.has(key)) {
- continue;
- }
-
- seen.add(key);
- result.push(item);
- }
-
- return result;
-};
diff --git a/src/types.ts b/src/types.ts
new file mode 100644
index 0000000..c188c4a
--- /dev/null
+++ b/src/types.ts
@@ -0,0 +1,28 @@
+/**
+ * Initialization options for FetchInterceptor.
+ */
+export interface FetchInterceptorOptions {
+ /**
+ * Determines whether a request should be intercepted.
+ * @param req A standard Request object.
+ * @returns Returns true when the request should be intercepted.
+ */
+ matcher?: (req: Request) => boolean;
+
+ /**
+ * Callback invoked when an intercepted request completes.
+ * @param req A standard Request object.
+ * @param res A standard Response object, or a cloned equivalent.
+ */
+ onIntercept: (req: Request, res: Response) => void;
+}
+
+/**
+ * FetchInterceptor instance exposed to consumers.
+ */
+export interface FetchInterceptor {
+ /** Starts interception. */
+ start: () => void;
+ /** Stops interception and restores the original global objects. */
+ stop: () => void;
+}
diff --git a/src/xhr.test.ts b/src/xhr.test.ts
new file mode 100644
index 0000000..d898842
--- /dev/null
+++ b/src/xhr.test.ts
@@ -0,0 +1,128 @@
+import { describe, expect, it, vi } from "vitest";
+
+import {
+ createXhrLoadHandler,
+ createXhrRequest,
+ createXhrResponse,
+ parseXhrResponseHeaders,
+} from "./xhr";
+
+describe("createXhrRequest", () => {
+ it("keeps request bodies for non-GET methods, including empty strings", async () => {
+ const request = createXhrRequest(
+ {
+ headers: new Headers({ "content-type": "text/plain" }),
+ method: "POST",
+ url: "https://example.com/body",
+ },
+ "",
+ );
+
+ expect(request.method).toBe("POST");
+ expect(request.headers.get("content-type")).toBe("text/plain");
+ expect(await request.text()).toBe("");
+ });
+
+ it("omits request bodies for GET requests", async () => {
+ const request = createXhrRequest(
+ {
+ headers: new Headers(),
+ method: "GET",
+ url: "https://example.com/no-body",
+ },
+ "ignored",
+ );
+
+ expect(request.method).toBe("GET");
+ expect(await request.text()).toBe("");
+ });
+});
+
+describe("parseXhrResponseHeaders", () => {
+ it("parses raw header strings and preserves colons in values", () => {
+ const headers = parseXhrResponseHeaders(
+ "content-type: application/json\r\nx-trace: foo:bar:baz\r\ninvalid-header",
+ );
+
+ expect(headers.get("content-type")).toBe("application/json");
+ expect(headers.get("x-trace")).toBe("foo:bar:baz");
+ });
+
+ it("returns empty headers for blank input", () => {
+ const headers = parseXhrResponseHeaders(" ");
+
+ expect(Array.from(headers.entries())).toEqual([]);
+ });
+});
+
+describe("createXhrResponse", () => {
+ it("builds a Response from xhr state", async () => {
+ const response = createXhrResponse({
+ getAllResponseHeaders: () =>
+ "content-type: application/json\r\nx-request-id: req-1",
+ response: JSON.stringify({ ok: true }),
+ responseText: "fallback",
+ status: 202,
+ statusText: "Accepted",
+ });
+
+ expect(response.status).toBe(202);
+ expect(response.statusText).toBe("Accepted");
+ expect(response.headers.get("x-request-id")).toBe("req-1");
+ expect(await response.json()).toEqual({ ok: true });
+ });
+});
+
+describe("createXhrLoadHandler", () => {
+ it("invokes onIntercept when matcher returns true", async () => {
+ const onIntercept = vi.fn();
+ const request = new Request("https://example.com/xhr", {
+ method: "POST",
+ });
+ const handleLoad = createXhrLoadHandler(
+ {
+ getAllResponseHeaders: () => "content-type: text/plain",
+ response: "ok",
+ responseText: "fallback",
+ status: 200,
+ statusText: "OK",
+ },
+ request,
+ {
+ matcher: () => true,
+ onIntercept,
+ },
+ );
+
+ handleLoad();
+
+ expect(onIntercept).toHaveBeenCalledOnce();
+
+ const [interceptedRequest, response] = onIntercept.mock.calls[0];
+
+ expect(interceptedRequest).toBe(request);
+ expect(await response.text()).toBe("ok");
+ });
+
+ it("skips onIntercept when matcher returns false", () => {
+ const onIntercept = vi.fn();
+ const handleLoad = createXhrLoadHandler(
+ {
+ getAllResponseHeaders: () => "",
+ response: null,
+ responseText: "",
+ status: 204,
+ statusText: "No Content",
+ },
+ new Request("https://example.com/xhr"),
+ {
+ matcher: () => false,
+ onIntercept,
+ },
+ );
+
+ handleLoad();
+
+ expect(onIntercept).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/xhr.ts b/src/xhr.ts
new file mode 100644
index 0000000..74160e2
--- /dev/null
+++ b/src/xhr.ts
@@ -0,0 +1,193 @@
+import type { FetchInterceptorOptions } from "./types";
+
+// Tracks request metadata associated with each XHR instance.
+export interface XhrInterceptorData {
+ method: string;
+ url: string;
+ headers: Headers;
+}
+
+type RuntimeInterceptorOptions = Omit & {
+ matcher: NonNullable;
+};
+
+type XhrOpenShort = (method: string, url: string | URL) => void;
+type XhrOpenLong = (
+ method: string,
+ url: string | URL,
+ async: boolean,
+ username?: string | null,
+ password?: string | null,
+) => void;
+
+type XhrResponseSource = Pick<
+ XMLHttpRequest,
+ | "getAllResponseHeaders"
+ | "response"
+ | "responseText"
+ | "status"
+ | "statusText"
+>;
+
+function toRequestBody(body: Document | XMLHttpRequestBodyInit): BodyInit {
+ if (typeof Document !== "undefined" && body instanceof Document) {
+ return new XMLSerializer().serializeToString(body);
+ }
+
+ return body as BodyInit;
+}
+
+export function createXhrRequest(
+ data: XhrInterceptorData,
+ body?: Document | XMLHttpRequestBodyInit | null,
+): Request {
+ const requestInit: RequestInit = {
+ method: data.method,
+ headers: data.headers,
+ };
+
+ if (body != null && data.method !== "GET" && data.method !== "HEAD") {
+ requestInit.body = toRequestBody(body);
+ }
+
+ return new Request(data.url, requestInit);
+}
+
+export function parseXhrResponseHeaders(rawHeaders: string): Headers {
+ const headers = new Headers();
+ const trimmedHeaders = rawHeaders.trim();
+
+ if (!trimmedHeaders) {
+ return headers;
+ }
+
+ for (const line of trimmedHeaders.split(/[\r\n]+/)) {
+ const separatorIndex = line.indexOf(":");
+
+ if (separatorIndex === -1) {
+ continue;
+ }
+
+ const name = line.slice(0, separatorIndex).trim();
+ const value = line.slice(separatorIndex + 1).trim();
+
+ if (name) {
+ headers.append(name, value);
+ }
+ }
+
+ return headers;
+}
+
+export function createXhrResponse(xhr: XhrResponseSource): Response {
+ const responseBody = xhr.response ?? xhr.responseText;
+
+ return new Response(responseBody, {
+ status: xhr.status,
+ statusText: xhr.statusText,
+ headers: parseXhrResponseHeaders(xhr.getAllResponseHeaders()),
+ });
+}
+
+export function createXhrLoadHandler(
+ xhr: XhrResponseSource,
+ request: Request,
+ options: RuntimeInterceptorOptions,
+): () => void {
+ return () => {
+ if (!options.matcher(request)) {
+ return;
+ }
+
+ options.onIntercept(request, createXhrResponse(xhr));
+ };
+}
+
+/**
+ * Intercepts XMLHttpRequest and returns a restore function.
+ */
+export function interceptXhr(options: RuntimeInterceptorOptions): () => void {
+ if (typeof XMLHttpRequest === "undefined") {
+ return () => {};
+ }
+
+ const originalXhrOpen = XMLHttpRequest.prototype.open;
+ const originalXhrOpenShort = originalXhrOpen as XhrOpenShort;
+ const originalXhrOpenLong = originalXhrOpen as XhrOpenLong;
+ const originalXhrSend = XMLHttpRequest.prototype.send;
+ const originalXhrSetRequestHeader = XMLHttpRequest.prototype.setRequestHeader;
+
+ // Store metadata in a WeakMap keyed by each XHR instance.
+ // Entries disappear with the instance, so this does not leak memory.
+ const xhrDataMap = new WeakMap();
+
+ XMLHttpRequest.prototype.open = function (
+ method: string,
+ url: string | URL,
+ async?: boolean,
+ username?: string | null,
+ password?: string | null,
+ ) {
+ // Save state using the current XHR instance as the key.
+ xhrDataMap.set(this, {
+ method: method.toUpperCase(),
+ url: url.toString(),
+ headers: new Headers(),
+ });
+
+ if (
+ async === undefined &&
+ username === undefined &&
+ password === undefined
+ ) {
+ return originalXhrOpenShort.call(this, method, url);
+ }
+
+ return originalXhrOpenLong.call(
+ this,
+ method,
+ url,
+ async ?? true,
+ username,
+ password,
+ );
+ };
+
+ XMLHttpRequest.prototype.setRequestHeader = function (
+ ...args: Parameters
+ ) {
+ const [name, value] = args;
+ const data = xhrDataMap.get(this);
+
+ if (data) {
+ data.headers.append(name, value);
+ }
+
+ return originalXhrSetRequestHeader.apply(this, args);
+ };
+
+ XMLHttpRequest.prototype.send = function (
+ ...args: Parameters
+ ) {
+ const [body] = args;
+ const data = xhrDataMap.get(this);
+
+ // Only run interception logic when metadata exists in the WeakMap.
+ if (data) {
+ const request = createXhrRequest(data, body);
+ this.addEventListener(
+ "load",
+ createXhrLoadHandler(this, request, options),
+ );
+ }
+
+ return originalXhrSend.apply(this, args);
+ };
+
+ // Return a cleanup function.
+ return function restoreXhr() {
+ XMLHttpRequest.prototype.open = originalXhrOpen;
+ XMLHttpRequest.prototype.send = originalXhrSend;
+ XMLHttpRequest.prototype.setRequestHeader = originalXhrSetRequestHeader;
+ };
+}
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..2f67188
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,7 @@
+import { defineConfig } from "vitest/config";
+
+export default defineConfig({
+ test: {
+ include: ["src/**/*.test.ts"],
+ },
+});