Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"$schema": "https://unpkg.com/@changesets/config@3.1.1/schema.json",
"changelog": false,
"changelog": "@changesets/cli/changelog",
"commit": false,
"fixed": [],
"linked": [],
Expand Down
7 changes: 7 additions & 0 deletions .changeset/observational-interception.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"@hsblabs/fetch-interceptor": minor
---

Preserve original fetch and XHR outcomes when request or response observation fails, normalize XHR no-body and status-0 responses safely, and make adapter installation transactional.

Narrow `FetchInterceptorError` by transport and remove the runtime-only options type from package exports.
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,23 @@
# CHANGELOG

## 0.2.0

- Add `onError` support for failed `fetch` and `XMLHttpRequest` requests.
- Normalize failures with transport, reason, and cause details.
- Clean up XHR terminal event listeners correctly across repeated sends.
- Add workspace type checking through `pnpm test:types`.

## 0.1.1

- Preserve `RequestInit` overrides passed to `fetch(request, init)`.
- Restore fetch and XHR globals correctly when multiple interceptors stop out of order.
- Preserve original network results when interceptor callbacks throw or reject.
- Normalize JSON-style XHR responses into readable standard `Response` bodies.

## 0.1.0

- Release the first feature-complete public version of the fetch/XHR interceptor library.

## 0.0.1

- Initial release of `@hsblabs/fetch-interceptor`.
Expand Down
28 changes: 15 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ The library is designed to stay small at the call site. The example below interc
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";
matcher: (request) => {
const url = new URL(request.url);
return (
url.pathname.includes("/api/target-data") && request.method === "GET"
);
},
onIntercept: async (req, res) => {
onIntercept: async (request, response) => {
try {
const data = await res.json();
const data = await response.json();
console.log("Intercepted data:", data);

// For example, forward data from a Chrome extension's main world
Expand All @@ -69,10 +71,10 @@ const interceptor = createFetchInterceptor({
console.error("Failed to parse intercepted response:", error);
}
},
onError: (req, error) => {
onError: (request, error) => {
console.error(
"Intercepted request failed:",
req.url,
request.url,
error.transport,
error.reason,
error.cause,
Expand All @@ -87,7 +89,7 @@ interceptor.start();
// interceptor.stop();
```

If `matcher` throws, or if `onIntercept`/`onError` throws or returns a rejected promise, the library reports the failure with `console.error` and preserves the original network result. When the underlying fetch/XHR request itself fails before producing a response, `onError` receives a normalized descriptor containing the transport, failure reason, and raw cause.
If matching, response normalization, or a consumer callback fails, the library reports the failure with `console.error` and preserves the original network result. Only an underlying fetch/XHR failure is passed to `onError`. An XHR load with status 0 is represented by `Response.error()`, the only standard `Response` value with status 0; its body and headers are therefore unavailable.

## API Reference

Expand All @@ -99,16 +101,16 @@ Creates an interceptor instance used to start and stop traffic interception.

| Property | Type | Description |
| --- | --- | --- |
| `matcher` | `((req: Request) => boolean)?` | Predicate that decides whether a request should be intercepted. When omitted, all matching traffic is intercepted. Exceptions are reported and treated as a non-match. |
| `onIntercept` | `(req: Request, res: Response) => void \| Promise<void>` | Callback invoked when a matching request completes successfully. `res` is a cloned response for fetch, or an equivalent standard `Response` for XHR. Exceptions and rejected promises are reported without changing the original request outcome. |
| `onError` | `(req: Request, error: FetchInterceptorError) => void \| Promise<void>` | Callback invoked when a matching request fails before a response is produced. `error.transport` identifies `fetch` or `xhr`, `error.reason` is `error`, `abort`, or `timeout`, and `error.cause` contains the raw fetch rejection or XHR terminal event. Exceptions and rejected promises are reported without changing the original request outcome. |
| `matcher` | `((request: Request) => boolean)?` | Predicate that decides whether a request should be intercepted. When omitted, all traffic is intercepted. Exceptions are reported and treated as a non-match. |
| `onIntercept` | `(request: Request, response: Response) => void \| Promise<void>` | Callback invoked when a matching request completes successfully. `response` is an independent clone for fetch, or an equivalent standard `Response` for XHR. Exceptions and rejected promises are reported without changing the original request outcome. |
| `onError` | `(request: Request, error: FetchInterceptorError) => void \| Promise<void>` | Callback invoked only when the underlying transport fails before producing a response. Fetch can report `error` or `abort`; XHR can also report `timeout`. `cause` contains the raw fetch rejection or XHR terminal event. |

### `FetchInterceptor`

| 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. |
| `start()` | Overrides `fetch` and `XMLHttpRequest` to begin interception. Calling it more than once is safe. If installation fails, completed patches are rolled back and the interceptor remains stopped. |
| `stop()` | Stops interception and attempts to restore every original browser API even if one restoration fails. |

## Use Cases

Expand Down
32 changes: 23 additions & 9 deletions docs/README/ja.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ pnpm test:e2e:browser
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";
matcher: (request) => {
const url = new URL(request.url);
return (
url.pathname.includes("/api/target-data") && request.method === "GET"
);
},
onIntercept: async (req, res) => {
onIntercept: async (request, response) => {
try {
const data = await res.json();
const data = await response.json();
console.log("Intercepted data:", data);

// たとえば Chrome 拡張の main world から isolated world へ
Expand All @@ -69,6 +71,15 @@ const interceptor = createFetchInterceptor({
console.error("Failed to parse intercepted response:", error);
}
},
onError: (request, error) => {
console.error(
"Intercepted request failed:",
request.url,
error.transport,
error.reason,
error.cause,
);
},
});

interceptor.start();
Expand All @@ -78,6 +89,8 @@ interceptor.start();
// interceptor.stop();
```

条件判定、レスポンス正規化、利用側コールバックのいずれかが失敗しても、ライブラリは `console.error` へ報告し、元の通信結果を維持します。`onError` に渡されるのは基盤の fetch/XHR 自体が失敗した場合だけです。status 0 で完了した XHR は、status 0 を持てる唯一の標準 `Response` である `Response.error()` として表現されるため、本文とヘッダーは利用できません。

## API リファレンス

### `createFetchInterceptor(options: FetchInterceptorOptions): FetchInterceptor`
Expand All @@ -88,15 +101,16 @@ interceptor.start();

| プロパティ | 型 | 説明 |
| --- | --- | --- |
| `matcher` | `((req: Request) => boolean)?` | リクエストを傍受するかを判定する述語です。省略時はすべての通信を傍受します。 |
| `onIntercept` | `(req: Request, res: Response) => void` | 条件に一致した通信完了時に呼ばれるコールバックです。`res` は fetch では clone されたレスポンス、XHR では等価な標準 `Response` です。 |
| `matcher` | `((request: Request) => boolean)?` | リクエストを傍受するかを判定する述語です。省略時はすべての通信を傍受します。例外は報告され、条件不一致として扱われます。 |
| `onIntercept` | `(request: Request, response: Response) => void \| Promise<void>` | 条件に一致した通信完了時に呼ばれるコールバックです。`response` は fetch では独立した clone、XHR では等価な標準 `Response` です。コールバックの失敗は元の通信結果を変更しません。 |
| `onError` | `(request: Request, error: FetchInterceptorError) => void \| Promise<void>` | レスポンス生成前に基盤の通信が失敗した場合だけ呼ばれます。fetch は `error` または `abort`、XHR は加えて `timeout` を報告できます。 |

### `FetchInterceptor`

| メソッド | 説明 |
| --- | --- |
| `start()` | `fetch` と `XMLHttpRequest` を上書きして傍受を開始します。複数回呼んでも安全です。 |
| `stop()` | 傍受を停止し、元のブラウザ API を復元します。 |
| `start()` | `fetch` と `XMLHttpRequest` を上書きして傍受を開始します。導入に失敗した場合は完了済みの変更を戻し、停止状態を維持します。 |
| `stop()` | 傍受を停止します。一部の復元に失敗しても、必要な復元をすべて試みます。 |

## ユースケース

Expand Down
32 changes: 23 additions & 9 deletions docs/README/ko.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ pnpm test:e2e:browser
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";
matcher: (request) => {
const url = new URL(request.url);
return (
url.pathname.includes("/api/target-data") && request.method === "GET"
);
},
onIntercept: async (req, res) => {
onIntercept: async (request, response) => {
try {
const data = await res.json();
const data = await response.json();
console.log("Intercepted data:", data);

// 예를 들어 Chrome 확장의 main world 에서
Expand All @@ -69,6 +71,15 @@ const interceptor = createFetchInterceptor({
console.error("Failed to parse intercepted response:", error);
}
},
onError: (request, error) => {
console.error(
"Intercepted request failed:",
request.url,
error.transport,
error.reason,
error.cause,
);
},
});

interceptor.start();
Expand All @@ -78,6 +89,8 @@ interceptor.start();
// interceptor.stop();
```

매칭, 응답 정규화 또는 사용자 콜백이 실패해도 라이브러리는 `console.error` 로 보고하고 원래 네트워크 결과를 유지합니다. 기반 fetch/XHR 자체가 실패한 경우에만 `onError` 를 호출합니다. status 0 으로 완료된 XHR 은 status 0 을 가질 수 있는 유일한 표준 `Response` 인 `Response.error()` 로 표현되므로 본문과 헤더는 사용할 수 없습니다.

## API 레퍼런스

### `createFetchInterceptor(options: FetchInterceptorOptions): FetchInterceptor`
Expand All @@ -88,15 +101,16 @@ interceptor.start();

| 속성 | 타입 | 설명 |
| --- | --- | --- |
| `matcher` | `((req: Request) => boolean)?` | 요청을 가로챌지 결정하는 predicate 입니다. 생략하면 모든 트래픽을 가로챕니다. |
| `onIntercept` | `(req: Request, res: Response) => void` | 조건에 맞는 요청이 완료되면 호출되는 콜백입니다. `res` 는 fetch 에서는 clone 된 응답이고, XHR 에서는 이에 상응하는 표준 `Response` 입니다. |
| `matcher` | `((request: Request) => boolean)?` | 요청을 가로챌지 결정하는 predicate 입니다. 생략하면 모든 트래픽을 가로챕니다. 예외는 보고되고 조건 불일치로 처리됩니다. |
| `onIntercept` | `(request: Request, response: Response) => void \| Promise<void>` | 조건에 맞는 요청이 완료되면 호출됩니다. `response` 는 fetch 에서는 독립된 clone 이고, XHR 에서는 이에 상응하는 표준 `Response` 입니다. 콜백 실패는 원래 네트워크 결과를 변경하지 않습니다. |
| `onError` | `(request: Request, error: FetchInterceptorError) => void \| Promise<void>` | 응답이 만들어지기 전에 기반 전송이 실패한 경우에만 호출됩니다. fetch 는 `error` 또는 `abort`, XHR 은 추가로 `timeout` 을 보고할 수 있습니다. |

### `FetchInterceptor`

| 메서드 | 설명 |
| --- | --- |
| `start()` | `fetch` 와 `XMLHttpRequest` 를 override 하여 인터셉션을 시작합니다. 여러 번 호출해도 안전합니다. |
| `stop()` | 인터셉션을 중지하고 원래 브라우저 API 를 복원합니다. |
| `start()` | `fetch` 와 `XMLHttpRequest` 를 override 하여 인터셉션을 시작합니다. 설치가 실패하면 완료된 변경을 되돌리고 중지 상태를 유지합니다. |
| `stop()` | 인터셉션을 중지합니다. 일부 복원이 실패해도 필요한 모든 복원을 시도합니다. |

## 사용 사례

Expand Down
32 changes: 23 additions & 9 deletions docs/README/zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,13 +53,15 @@ pnpm test:e2e:browser
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";
matcher: (request) => {
const url = new URL(request.url);
return (
url.pathname.includes("/api/target-data") && request.method === "GET"
);
},
onIntercept: async (req, res) => {
onIntercept: async (request, response) => {
try {
const data = await res.json();
const data = await response.json();
console.log("Intercepted data:", data);

// 例如,将数据从 Chrome 扩展的 main world
Expand All @@ -69,6 +71,15 @@ const interceptor = createFetchInterceptor({
console.error("Failed to parse intercepted response:", error);
}
},
onError: (request, error) => {
console.error(
"Intercepted request failed:",
request.url,
error.transport,
error.reason,
error.cause,
);
},
});

interceptor.start();
Expand All @@ -78,6 +89,8 @@ interceptor.start();
// interceptor.stop();
```

匹配、响应规范化或调用方回调失败时,库会通过 `console.error` 报告并保留原始网络结果。只有底层 fetch/XHR 本身失败时才会调用 `onError`。以 status 0 完成的 XHR 会表示为 `Response.error()`,因为它是唯一能持有 status 0 的标准 `Response`;因此响应体和响应头不可用。

## API 参考

### `createFetchInterceptor(options: FetchInterceptorOptions): FetchInterceptor`
Expand All @@ -88,15 +101,16 @@ interceptor.start();

| 属性 | 类型 | 说明 |
| --- | --- | --- |
| `matcher` | `((req: Request) => boolean)?` | 用于判断请求是否应被拦截的谓词。省略时将拦截所有流量。 |
| `onIntercept` | `(req: Request, res: Response) => void` | 当匹配请求完成时调用的回调。`res` 在 fetch 场景中是克隆后的响应,在 XHR 场景中是等价的标准 `Response`。 |
| `matcher` | `((request: Request) => boolean)?` | 用于判断请求是否应被拦截的谓词。省略时将拦截所有流量。异常会被报告并视为不匹配。 |
| `onIntercept` | `(request: Request, response: Response) => void \| Promise<void>` | 当匹配请求完成时调用的回调。`response` 在 fetch 场景中是独立克隆,在 XHR 场景中是等价的标准 `Response`。回调失败不会改变原始网络结果。 |
| `onError` | `(request: Request, error: FetchInterceptorError) => void \| Promise<void>` | 仅在底层传输于生成响应前失败时调用。fetch 可报告 `error` 或 `abort`,XHR 还可报告 `timeout`。 |

### `FetchInterceptor`

| 方法 | 说明 |
| --- | --- |
| `start()` | 覆盖 `fetch` 和 `XMLHttpRequest` 以开始拦截。重复调用也是安全的。 |
| `stop()` | 停止拦截并恢复原始浏览器 API。 |
| `start()` | 覆盖 `fetch` 和 `XMLHttpRequest` 以开始拦截。安装失败时会回滚已完成的修改并保持停止状态。 |
| `stop()` | 停止拦截。即使某一步恢复失败,也会尝试所有必要的恢复操作。 |

## 使用场景

Expand Down
31 changes: 31 additions & 0 deletions docs/adr/2026-08-01-observational-interception.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
title: Preserve observational interception across all failure paths
status: accepted
date: 2026-08-01T02:37:54+09:00
agent: GPT-5 Codex
---

# Preserve observational interception across all failure paths

## Context

The interceptor promises that matcher and callback failures do not change the original network result. The same invariant must also cover request and response normalization, callback response cloning, startup, and shutdown.

The existing implementation has two independent handler paths used by production and tests. It also exposes resolved runtime options from the package entrypoint and permits transport/reason error combinations that the implementation cannot produce.

## Decision

- Only a rejection from the underlying `fetch` or an XHR terminal failure event is reported through `onError`.
- Matcher, callback, cloning, and normalization failures are reported to `console.error` and never replace the original network result.
- XHR statuses 204, 205, and 304 produce a `Response` with a null body. XHR status 0 is represented by `Response.error()`, the standard `Response` value that can carry status 0.
- Installing the fetch and XHR adapters is transactional. A failed installation restores every adapter already installed and leaves the interceptor stopped.
- `FetchInterceptorError` is a discriminated union keyed by `transport`, so fetch cannot report the XHR-only `timeout` reason.
- Tests exercise callback and lifecycle behavior through `createFetchInterceptor`. Pure request/response normalization remains an internal seam with focused tests.
- Only consumer-facing types are exported from the package entrypoint. Resolved runtime options remain internal.

## Consequences

- The original fetch/XHR outcome remains authoritative even when observation cannot be completed.
- XHR status 0 cannot retain response body or headers because the standard `Response` interface has no constructible successful status-0 representation.
- The earlier direct-handler testing approach is superseded. Normalization helpers remain directly testable, while transport behavior is tested through the public interface.
- Public declaration comments must be preserved so consumers can see lifecycle and failure contracts in editor tooling.
24 changes: 24 additions & 0 deletions docs/tickets/2026-08-01-code-quality-hardening.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
---
title: Code quality hardening tickets
status: completed
date: 2026-08-01
---

# Code quality hardening tickets

## Completion criteria

- Observation failures never alter the original fetch/XHR outcome or invoke `onError` as a transport failure.
- Every representable XHR terminal success produces a standard `Response` without throwing.
- Startup and shutdown keep registry state and installed global adapters consistent on failure.
- Public types exclude runtime-only details and make invalid transport/reason combinations unrepresentable.
- Production behavior has one implementation path, naming is explicit, pure normalization is separated from global I/O, and public JSDoc ships in declarations.
- Unit, type, build, Node E2E, and browser E2E checks pass.

## Tracer tickets

- [x] QH-1: Add public-interface regressions for consumed fetch responses, XHR no-body/status-0 responses, callback failures, and failed startup.
- [x] QH-2: Make response observation safe and normalize XHR no-body/status-0 responses. Blocked by QH-1.
- [x] QH-3: Make lifecycle transitions transactional and model transport failures as a discriminated union. Blocked by QH-1.
- [x] QH-4: Remove duplicate handler paths, separate XHR normalization, tighten exports and naming, and preserve useful public JSDoc. Blocked by QH-2 and QH-3.
- [x] QH-5: Run declaration, unit, type, build, Node E2E, and browser E2E verification. Blocked by QH-1 through QH-4.
Loading
Loading