npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@klogt/intercept

v2.0.2

Published

The lightweight HTTP interception library built for modern Node.js testing. MSW-inspired simplicity meets native `fetch` performance.

Readme

@klogt/intercept

Stop writing mocks. Start declaring routes.

The lightweight HTTP interception library built for modern Node.js testing.
MSW-inspired simplicity meets native fetch performance.

npm version npm downloads License: MIT TypeScript Node.js CI


Why Choose @klogt/intercept?

Testing APIs shouldn't be hard. You shouldn't need to:

  • Mock individual functions for every network call
  • Set up complex test servers
  • Learn heavyweight frameworks when you just need simple interception
  • Wait for slow, flaky tests

With @klogt/intercept, you declare routes right in your tests — exactly like you think about them.

// That's it. No setup files, no complex config.
intercept.get("/users").resolve([{ id: 1, name: "Ada" }]);

Built for Modern JavaScript

Native Node 20+ fetch — no polyfills, no patches, just works
🎯 Zero dependencies — only 2.6kb gzipped, installs in milliseconds
🔧 Works with your stack — Vitest, Jest, React Testing Library, TanStack Query 🚀 Millisecond setup — one file, three lines, done
📦 TypeScript-first — full type inference, no any in sight
Lightning fast — tests run at native speed

Perfect For

  • 🧪 Frontend developers testing React, Vue, or Svelte apps
  • 🏗️ Integration tests that need predictable API responses
  • 🔄 TanStack Query users who want cleaner test mocks
  • 📱 Component testing with React Testing Library
  • ⚙️ CI/CD pipelines where speed matters

Quick Example

import { intercept } from "@klogt/intercept";

it("fetches and displays users", async () => {
  // Start intercepting with your API origin
  intercept.listen({
    origin: 'https://api.example.com',
    onUnhandledRequest: 'error'
  });

  // Declare what the API should return
  intercept.get("/users").resolve([
    { id: 1, name: "Ada Lovelace" },
    { id: 2, name: "Grace Hopper" }
  ]);

  // Run your component/code
  render(<UserList />);

  // Assert the results
  expect(await screen.findByText("Ada Lovelace")).toBeInTheDocument();
  expect(await screen.findByText("Grace Hopper")).toBeInTheDocument();
});

No mocks. No spies. No complexity. Just declare routes and test.


Features That Matter

🎯 Inline route declarations — define mocks exactly where you need them
📝 Smart defaultsPOST returns 201, DELETE returns 204, etc.
🔍 Path parameters/users/:id just works
⏱️ Delay simulation — test loading states with .delay(500)
🚨 Error scenarios.reject({ status: 500 }) for error testing
🔄 Works with Axios — optional adapter for non-fetch clients
🧹 Test isolationintercept.reset() between tests
🛠️ Unhandled request strategies — warn, bypass, or error on unexpected calls


Table of Contents


Requirements

  • Node 20+ (uses built-in fetch)
  • Test runner: Vitest or Jest

Installation

npm

npm i -D @klogt/intercept

pnpm

pnpm add -D @klogt/intercept

yarn

yarn add -D @klogt/intercept

No need to install axios unless you plan to attach the Axios adapter.


Quick start (Vitest)

Getting started: Inline approach

The simplest way to get started is to call intercept.listen() directly in your tests:

import { intercept } from "@klogt/intercept";

it("fetches users from API", async () => {
  // Start intercepting with your API origin
  intercept.listen({
    origin: 'https://api.example.com',
    onUnhandledRequest: 'error'
  });

  // Declare what the API should return
  intercept.get("/users").resolve([
    { id: 1, name: "Ada" },
    { id: 2, name: "Grace" }
  ]);

  // Your code calls fetch('/users') - it gets the mocked response
  const res = await fetch('/users');
  const users = await res.json();
  
  expect(users).toHaveLength(2);
  expect(users[0].name).toBe("Ada");
});

This works great for quick tests! However, you'll likely want to extract the setup to avoid repetition.

Note: onUnhandledRequest defaults to 'error' in test environments (Vitest/Jest) and 'warn' otherwise, so you can omit it if the default works for you.

Setup Option 1: Using setupIntercept() helper

For minimal boilerplate, use the setupIntercept() helper in a shared setup file:

// setupTests.ts
import { setupIntercept } from "@klogt/intercept";

setupIntercept({
  origin: 'https://api.example.com',
  onUnhandledRequest: 'error'
});

This automatically registers beforeAll, afterEach, and afterAll hooks for you. Then configure this file in your vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    setupFiles: ['./setupTests.ts'],
  },
});

Now your tests can be clean and focused:

import { intercept } from "@klogt/intercept";

it("fetches users from API", async () => {
  // Just declare routes - setup is handled automatically
  intercept.get("/users").resolve([
    { id: 1, name: "Ada" },
    { id: 2, name: "Grace" }
  ]);

  const res = await fetch('/users');
  const users = await res.json();
  
  expect(users).toHaveLength(2);
});

Alternative: Per-test suite setup

If you prefer to keep setup within your test files, you can use describe blocks with lifecycle hooks:

import { intercept } from "@klogt/intercept";

describe("User API", () => {
  beforeAll(() => {
    intercept.listen({
      origin: 'https://api.example.com',
      onUnhandledRequest: 'error'
    });
  });

  afterEach(() => {
    intercept.reset();
  });

  afterAll(() => {
    intercept.close();
  });

  it("fetches users", async () => {
    intercept.get("/users").resolve([
      { id: 1, name: "Ada" },
      { id: 2, name: "Grace" }
    ]);

    const res = await fetch('/users');
    const users = await res.json();
    
    expect(users).toHaveLength(2);
  });

  it("handles errors", async () => {
    intercept.get("/users").reject({ status: 500 });
    
    await expect(fetch('/users')).rejects.toThrow();
  });
});

This approach gives you control over the interceptor lifecycle per test suite without needing a shared setup file.

Setup Option 2: Shared setup file (manual approach)

For maximum control (custom logging, conditional setup, etc.), you can manually manage the lifecycle hooks:

// setupTests.ts
import { intercept } from "@klogt/intercept";

beforeAll(() => {
  intercept.listen({
    origin: 'https://api.example.com',
    onUnhandledRequest: 'error'
  });
});

afterEach(() => {
  intercept.reset();
});

afterAll(() => {
  intercept.close();
});

Note: This is what setupIntercept() does for you under the hood. Use this manual approach when you need to add custom logic to the lifecycle hooks.

Configure this file in your vitest.config.ts:

// vitest.config.ts
import { defineConfig } from 'vitest/config';

export default defineConfig({
  test: {
    setupFiles: ['./setupTests.ts'],
  },
});

Understanding Origins

When you use relative paths like /users, you need to set a base URL:

intercept.listen({ 
  origin: 'https://api.example.com',
  onUnhandledRequest: 'error'
});

intercept.get("/users").resolve([{ id: 1 }]);
// Matches: https://api.example.com/users

Absolute URLs bypass the origin and match exactly:

intercept.get("https://payments.stripe.com/v1/charges").resolve({ id: "ch_123" });
// Matches exactly - ignores origin setting

You can mix relative and absolute URLs in the same test as needed.


Using with React + TanStack Query

@klogt/intercept plays nicely with data fetching libraries like TanStack Query.

Here's a simple example component:

// Users.tsx
import { useQuery } from "@tanstack/react-query";

export function Users() {
  const { data, isLoading, error } = useQuery({
    queryKey: ["users"],
    queryFn: async () => {
      const res = await fetch("/users");
      if (!res.ok) throw new Error("Failed to fetch users");
      return res.json();
    },
  });

  if (isLoading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;
  
  return (
    <ul>
      {data.map((u: { id: number; name: string }) => (
        <li key={u.id}>{u.name}</li>
      ))}
    </ul>
  );
}

Test with intercept

// Users.test.tsx
import { render, screen } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { Users } from "./Users";
import { intercept } from "@klogt/intercept";

function renderWithQuery(ui: React.ReactElement) {
  const queryClient = new QueryClient({
    defaultOptions: {
      queries: { retry: false },
    },
  });
  return render(
    <QueryClientProvider client={queryClient}>
      {ui}
    </QueryClientProvider>
  );
}

it("renders users from mocked API", async () => {
  intercept.get("/users").resolve([
    { id: 1, name: "Ada" },
    { id: 2, name: "Grace" },
  ]);

  renderWithQuery(<Users />);

  expect(await screen.findByText("Ada")).toBeInTheDocument();
  expect(await screen.findByText("Grace")).toBeInTheDocument();
});

it("shows error state on API failure", async () => {
  intercept.get("/users").reject({
    status: 500,
    body: { message: "Server error" }
  });

  renderWithQuery(<Users />);

  expect(await screen.findByText(/Error:/)).toBeInTheDocument();
});

Defining routes

Basic responses

// GET /users → 200 with array
intercept.get("/users").resolve([{ id: 1, name: "Ada" }]);

// POST /users → 201 with body
intercept.post("/users").resolve({ id: 2, name: "Grace" });

// DELETE /users/2 → 204 (no content)
intercept.delete("/users/:id").resolve(null, { status: 204 });

// PUT with custom status and headers
intercept.put("/profile").resolve(
  { id: "me", updated: true },
  {
    status: 200,
    headers: { "x-request-id": "abc123" },
  }
);

Error responses

// Reject with error status and body
intercept.post("/login").reject({
  status: 401,
  body: { code: "INVALID_CREDENTIALS", message: "Invalid username or password" }
});

// 404 Not Found
intercept.get("/users/:id").reject({
  status: 404,
  body: { error: "User not found" }
});

// 422 Validation Error
intercept.post("/users").reject({
  status: 422,
  body: {
    errors: [
      { field: "email", message: "Email is required" }
    ]
  }
});

Path parameters

// Match dynamic segments with :param
intercept.get("/users/:id").resolve({ id: "123", name: "Ada" });

// Access params in dynamic resolver
intercept.get("/users/:id").handle(({ params }) => {
  return Response.json({
    id: params.id,
    name: `User ${params.id}`
  });
});

// Multiple params
intercept.get("/orgs/:orgId/repos/:repoId").handle(({ params }) => {
  return Response.json({
    org: params.orgId,
    repo: params.repoId
  });
});

// Catch-all wildcard
intercept.get("/*").resolve({ message: "Catch all requests" });

Adding delays with .delay()

Simulate network latency to test loading states:

// Resolve after 500ms
intercept.get("/users").delay(500).resolve([{ id: 1, name: "Ada" }]);

// Reject after 1 second
intercept.post("/login").delay(1000).reject({
  status: 401,
  body: { error: "Timeout" }
});

Dynamic resolvers with .handle()

Need to compute a response based on the incoming request? Use .handle() for full control:

intercept.post("/login").handle(async ({ request, body, params }) => {
  // body is parsed JSON (best-effort)
  if (body?.username === "admin" && body?.password === "secret") {
    return Response.json({ token: "abc123", user: { id: 1, name: "Admin" } });
  }
  
  return Response.json(
    { error: "Invalid credentials" },
    { status: 401 }
  );
});

// Access request headers
intercept.get("/protected").handle(({ request }) => {
  const auth = request.headers.get("Authorization");
  
  if (!auth) {
    return Response.json({ error: "Unauthorized" }, { status: 401 });
  }
  
  return Response.json({ data: "secret data" });
});

// Use path params
intercept.delete("/users/:id").handle(({ params }) => {
  console.log(`Deleting user ${params.id}`);
  return new Response(null, { status: 204 });
});

Ignoring requests

Ignore requests to given paths across all HTTP methods. Handy for analytics, health checks, or any traffic you don't want your tests to care about. Returns 204 No Content immediately to prevent test failures.

intercept.ignore(paths: ReadonlyArray<Path>)

Example:

// In setupTests.ts or beforeEach
intercept.ignore(['/analytics', '/ping', '/health-check']);

// All these return 204 immediately:
fetch('/analytics');           // GET
fetch('/ping', { method: 'POST' });  // POST
fetch('/health-check');        // any method

Common use cases:

// Ignore analytics and monitoring
intercept.ignore([
  '/api/analytics/*',
  '/api/metrics',
  '/api/telemetry'
]);

// Ignore third-party health checks
intercept.ignore([
  '/health',
  '/ready',
  '/livez'
]);

Unhandled requests

Configure once when starting the server:

intercept.listen({ onUnhandledRequest: "error" });

Strategies

  • "error" (recommended for tests): Blocks unhandled requests with 501 status and logs an error

    [@klogt/intercept] ❌ Unhandled request (error mode)
       → GET https://api.example.com/unknown
      
    No intercept handler matched this request.
    The request was blocked with a 501 response.
    Tip: add one with:
       intercept.get('/unknown').resolve(...)
  • "warn": Logs a warning but allows the request to pass through to the real transport

    [@klogt/intercept] 🚧 Unhandled request
       → GET https://api.example.com/unknown
      
    No intercept handler matched this request.
    Tip: add one with:
       intercept.get('/unknown').resolve(...)
  • "bypass": Silently allows requests to pass through (no logging)

  • Function: Decide dynamically per request

    intercept.listen({
      onUnhandledRequest: ({ request, url }) => {
        // Ignore OPTIONS requests
        if (request.method === 'OPTIONS') return 'bypass';
          
        // Warn about others
        return 'warn';
      }
    });

Axios adapter (optional)

@klogt/intercept can intercept both fetch and Axios. Simply pass your axios instance to intercept.listen():

import axios from "axios";

const apiClient = axios.create({
  baseURL: "https://api.example.com"
});

// In setupTests.ts
intercept.listen({
  onUnhandledRequest: 'error',
  adapter: apiClient  // Automatically wrapped and attached
});

No runtime axios dependency: the adapter's types reference axios conditionally so your library/app doesn't pull axios unless you install it yourself.

How it works: When you pass an axios instance to the adapter option, intercept automatically wraps it with an internal adapter that makes your axios requests go through the same route handlers as fetch requests.


Common Testing Patterns

Baseline routes with per-test overrides

// setupTests.ts
beforeAll(() => {
  intercept
    .listen({ onUnhandledRequest: 'error' })
    .origin('https://api.example.com');
    
  // Baseline routes that apply to all tests
  intercept.get("/config").resolve({ version: "1.0" });
  intercept.get("/health").resolve({ status: "ok" });
});

// users.test.ts
it("fetches users", () => {
  // Override or add specific routes for this test
  intercept.get("/users").resolve([{ id: 1 }]);
  // ... test code
});

Testing error scenarios

it("handles network errors gracefully", async () => {
  intercept.get("/users").reject({
    status: 500,
    body: { error: "Internal server error" }
  });

  const { result } = renderHook(() => useUsers(), {
    wrapper: QueryClientProvider
  });

  await waitFor(() => {
    expect(result.current.error).toBeTruthy();
  });
});

Ignoring non-essential requests

beforeEach(() => {
  // Ignore analytics so they don't cause test failures
  intercept.ignore([
    '/api/analytics/*',
    '/api/tracking',
    '/health'
  ]);
});

API reference

intercept.listen(options)

Start intercepting. Must be called before defining routes.

intercept.listen({
  origin?: string;
  onUnhandledRequest?: "warn" | "bypass" | "error" | ((args) => Strategy);
  adapter?: unknown;
});

Parameters:

  • origin: (optional) Base URL for relative paths. Can also be set later via .origin()
  • onUnhandledRequest: (optional) Strategy for unhandled requests. Defaults to 'error' in test environments (Vitest/Jest) and 'warn' otherwise
  • adapter: (optional) Axios instance to intercept (see Axios adapter section)

Returns intercept for chaining with .origin().

intercept.origin(url)

Set the base URL for relative paths. Can be called in beforeAll (applies to whole file) or beforeEach (per-test).

intercept.origin('https://api.example.com');

Returns intercept for chaining.

intercept.<method>(path)

Create a route for GET | POST | PUT | PATCH | DELETE | OPTIONS.

intercept.get(path: Path)
intercept.post(path: Path)
intercept.put(path: Path)
intercept.patch(path: Path)
intercept.delete(path: Path)
intercept.options(path: Path)

Each returns an object with:

.delay(ms)

Add a delay before responding. Returns a chainable object with resolve/reject/handle methods:

delay(ms: number): {
  resolve<T>(data: T, init?: ResolveInit): void;
  reject<T>(opts?: RejectInit<T>): void;
  handle<TRequest>(resolver: DynamicResolver<TRequest>): void;
}

Example:

intercept.get("/users").delay(500).resolve([{ id: 1 }]);
intercept.post("/login").delay(1000).reject({ status: 401 });

.resolve(data, init?)

Return successful JSON response:

resolve<T>(data: T, init?: {
  status?: number;      // Default: method-specific (200, 201, 204)
  headers?: Record<string, string>;
}): void;

If status is 204, body is stripped automatically.

.reject(opts?)

Return error response:

reject<T>(opts?: {
  status?: number;      // Default: 400
  body?: T | undefined; // Optional error body
  headers?: Record<string, string>;
}): void;

.fetching(init?)

Simulate pending/loading state:

fetching(init?: {
  delayMs?: number;     // Undefined = hang forever
  status?: number;      // Default: 204
  headers?: Record<string, string>;
}): void;

.handle(resolver)

Full control with custom logic:

handle<TRequest>(
  resolver: (args: {
    request: Request;
    url: URL;
    params: Record<string, string>;
    body: TRequest | undefined;  // Parsed JSON, best-effort
  }) => Response | Promise<Response>
): void;

intercept.ignore(paths)

Ignore requests to given paths across all HTTP methods:

intercept.ignore(paths: ReadonlyArray<Path>): void;

Returns 204 No Content immediately.

intercept.reset()

Clear all registered handlers. Use in afterEach for test isolation:

afterEach(() => {
  intercept.reset();
});

intercept.close()

Stop intercepting, detach adapters, restore globals, and clear all state. Use in afterAll:

afterAll(() => {
  intercept.close();
});

setupIntercept(options)

Convenience helper that automatically registers lifecycle hooks for minimal boilerplate. Use this when you don't need custom logic in your hooks.

setupIntercept(options?: {
  origin?: string;
  onUnhandledRequest?: "warn" | "bypass" | "error" | ((args) => Strategy);
  adapter?: unknown;
}): void;

Parameters:

  • options: (optional) Configuration object with the same options as intercept.listen():
    • origin: Base URL for relative paths
    • onUnhandledRequest: Strategy for unhandled requests (default: auto-detected)
    • adapter: Axios instance to intercept

What it does:

  • Automatically calls intercept.listen(options) in a beforeAll hook
  • Automatically calls intercept.reset() in an afterEach hook
  • Automatically calls intercept.close() in an afterAll hook

Example:

// setupTests.ts
import { setupIntercept } from "@klogt/intercept";

setupIntercept({
  origin: 'https://api.example.com',
  onUnhandledRequest: 'error'
});

When to use:

  • You have a single API origin for your entire test suite
  • You don't need custom logic in lifecycle hooks
  • You want minimal boilerplate

When to use manual setup instead:

  • You need custom logging or debugging in hooks
  • You need conditional or dynamic setup
  • You need to integrate with other test setup/teardown
  • You need per-test origins

Troubleshooting

"No intercept handler matched this request"

This error means you tried to make a request that doesn't have a matching route. The error message now includes helpful context:

[@klogt/intercept] ❌ Unhandled request (error mode)
   → GET https://api.example.com/user

No intercept handler matched this request.
The request was blocked with a 501 response.

Registered handlers:
  GET /users
  POST /users
  GET /profile

Did you mean: GET /users?

Common causes:

  1. Forgot to set origin for relative paths:

    // ❌ This won't work
    intercept.get("/users").resolve([...]);
    await fetch('/users');  // Error: no handler matched
       
    // ✅ Set origin first
    intercept.origin('https://api.example.com');
    intercept.get("/users").resolve([...]);
    await fetch('/users');  // Works!
  2. Path mismatch (typo or wrong params):

    intercept.get("/users").resolve([...]);
    await fetch('/user');  // ❌ Typo - check "Did you mean?" suggestion
  3. Method mismatch:

    intercept.get("/users").resolve([...]);
    await fetch('/users', { method: 'POST' });  // ❌ Handler is for GET

Tip: The error message now shows all registered handlers and may suggest the closest match to help you spot typos quickly!

"Cannot find module 'axios' or its type declarations"

You don't need axios unless you attach the adapter. If you see this error:

  1. Either install axios: npm i axios
  2. Or don't import from @klogt/intercept/axios

The axios adapter uses conditional type imports to avoid a hard dependency.

Tests are flaky or handlers leak between tests

Always call intercept.reset() in afterEach:

afterEach(() => {
  intercept.reset();
});

This ensures each test starts with a clean slate.

Relative paths don't work

Make sure you've called .origin():

// In setupTests.ts or beforeAll
intercept
  .listen({ onUnhandledRequest: 'error' })
  .origin('https://api.example.com');

Or use absolute URLs:

intercept.get("https://api.example.com/users").resolve([...]);

Comparison with MSW

If you're familiar with MSW, here's how @klogt/intercept compares:

| Feature | @klogt/intercept | MSW | |---------|------------------|-----| | Setup complexity | Minimal - one setup file | Requires worker setup | | Native fetch support | Built-in (Node 20+) | Via msw/node | | Route definition | Inline in tests | Inline or separate handlers | | Path params | :param syntax | :param syntax | | TypeScript | First-class, no any | Good support | | Browser support | No (test-only) | Yes | | Bundle size | Small | Larger | | Maturity | New | Established |

Choose @klogt/intercept if:

  • You only need test interception (not browser)
  • You want minimal setup
  • You prefer Node 20+ native features
  • You want a lightweight package

Choose MSW if:

  • You need browser support (dev mode mocking)
  • You want battle-tested stability
  • You need advanced features like streaming

Contributing

Contributions are welcome! Please open an issue or PR on GitHub.


License

MIT © Klogt