DEV Community

陈盛樟
陈盛樟

Posted on

One Hook to Intercept All AJAX Requests: A Guide to ajax-hooker

One Hook to Intercept All AJAX Requests: A Guide to ajax-hooker

Need to modify API responses during debugging? Want to hijack network requests in a Tampermonkey script? Building a Chrome extension for request monitoring? ajax-hooker handles XMLHttpRequest and Fetch interception with a single API — and it supports streaming responses too.

The Problem: Why Do We Need AJAX Interception?

As a frontend developer, you've likely encountered these scenarios:

  • API development: Backend isn't ready yet, but you need mock data to keep building the UI
  • Production debugging: An API has a bug in production, and you want to temporarily modify responses to investigate
  • Request monitoring: Add auth tokens or tracking logs to all outgoing requests
  • Userscripts: Modify third-party website API behavior — ad removal, data transformation, etc.
  • Chrome extensions: Build network debugging tools

The challenge: XHR and Fetch are two completely different APIs. You either write two separate interception layers, or find a library that unifies them.

That's exactly what ajax-hooker does — one hook function intercepts both XHR and Fetch, with a unified request/response data structure and streaming response support.

Quick Start

Installation

# npm
npm install ajax-hooker

# pnpm
pnpm add ajax-hooker

# CDN (IIFE, global variable: AjaxHooker)
# https://unpkg.com/ajax-hooker
# https://cdn.jsdelivr.net/npm/ajax-hooker
Enter fullscreen mode Exit fullscreen mode

Three Steps to Intercept

import AjaxInterceptor from 'ajax-hooker';

// 1. Get singleton instance
const interceptor = AjaxInterceptor.getInstance();

// 2. Inject (replaces native XMLHttpRequest and fetch)
interceptor.inject();

// 3. Register hooks
interceptor.hook((request) => {
  console.log(`[${request.type}] ${request.method} ${request.url}`);

  // Modify request: add auth token
  request.headers.set('Authorization', 'Bearer my-token');

  // Modify response
  request.response = (resp) => {
    if (request.url.includes('/api/user')) {
      resp.json = { name: 'Test User', id: 1 };
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

This intercepts every XHR and Fetch request on the page. Whether a third-party library uses axios (XHR-based) or native fetch, it gets captured.

Real-World Use Cases

Use Case 1: API Version Migration

Backend is migrating from /api/v1/ to /api/v2/ — switch transparently on the frontend:

interceptor.hook((request) => {
  if (request.url.includes('/api/v1/')) {
    request.url = request.url.replace('/api/v1/', '/api/v2/');
  }
});
Enter fullscreen mode Exit fullscreen mode

You can even switch domains:

interceptor.hook((request) => {
  if (request.url.includes('old-api.example.com')) {
    request.url = request.url.replace(
      'old-api.example.com',
      'new-api.example.com'
    );
  }
});
Enter fullscreen mode Exit fullscreen mode

Use Case 2: Mock API Responses

Backend API isn't ready? Intercept and return mock data:

interceptor.hook((request) => {
  request.response = (resp) => {
    if (request.url.includes('/api/products')) {
      // For XHR: modify response/responseText
      resp.response = JSON.stringify([
        { id: 1, name: 'Product A', price: 99 },
        { id: 2, name: 'Product B', price: 199 },
      ]);
      resp.responseText = resp.response;
      // For Fetch: modify json
      resp.json = [
        { id: 1, name: 'Product A', price: 99 },
        { id: 2, name: 'Product B', price: 199 },
      ];
      resp.status = 200;
      resp.statusText = 'OK';
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

Use Case 3: Request Logging & Performance Monitoring

interceptor.hook((request) => {
  const startTime = Date.now();

  request.response = (resp) => {
    const duration = Date.now() - startTime;
    console.log(
      `[${request.type.toUpperCase()}] ${request.method} ${request.url}`,
      `| ${resp.status} | ${duration}ms`
    );

    // Alert on slow requests
    if (duration > 3000) {
      console.warn(`Slow API: ${request.url} took ${duration}ms`);
    }
  };
});
Enter fullscreen mode Exit fullscreen mode

Use Case 4: Add Common Query Parameters

interceptor.hook((request) => {
  const url = new URL(request.url);
  url.searchParams.set('app_version', '2.0.0');
  url.searchParams.set('platform', 'web');
  request.url = url.toString();
});
Enter fullscreen mode Exit fullscreen mode

Use Case 5: Intercept Streaming Responses (SSE / NDJSON)

With the rise of AI applications, streaming API responses are everywhere. ajax-hooker can intercept them chunk by chunk:

interceptor.hook((request) => {
  if (request.url.includes('/api/chat/stream')) {
    // onStreamChunk is called for each data chunk
    request.onStreamChunk = (chunk) => {
      console.log(`chunk #${chunk.index}:`, chunk.text);

      // Return modified text (replaces original data)
      return chunk.text.replace('sensitive-word', '***');

      // Return void/undefined to keep original data unchanged
    };

    request.response = (resp) => {
      console.log('Stream started, status:', resp.status);
    };
  }
});
Enter fullscreen mode Exit fullscreen mode

Auto-detected streaming Content-Types:

  • text/event-stream (SSE)
  • application/x-ndjson
  • application/stream+json
  • application/jsonl
  • application/json-seq

Use Case 6: Multiple Hooks Working Together

Hooks execute in registration order as a chain, enabling separation of concerns:

// Hook 1: Authentication
interceptor.hook((request) => {
  request.headers.set('Authorization', 'Bearer token-xxx');
});

// Hook 2: Logging
interceptor.hook((request) => {
  console.log('Request has Auth:', request.headers.get('Authorization'));
});

// Hook 3: Fetch-only response interception
interceptor.hook((request) => {
  if (request.type === 'fetch') {
    request.response = (resp) => {
      // Only handle fetch responses
    };
  }
});
Enter fullscreen mode Exit fullscreen mode

Use Case 7: Usage in Chrome Extensions

Chrome extension Content Scripts run in an isolated environment and can't directly modify the page's XMLHttpRequest. You need to inject into the page's main world:

// content.js
const script = document.createElement('script');
script.src = chrome.runtime.getURL('vendor/ajax-hooker.iife.js');
script.onload = () => {
  const init = document.createElement('script');
  init.textContent = `
    const interceptor = AjaxHooker.getInstance();
    interceptor.inject();
    interceptor.hook((request) => {
      // Your interception logic
    });
  `;
  document.documentElement.appendChild(init);
  init.remove();
};
document.documentElement.appendChild(script);
script.remove();
Enter fullscreen mode Exit fullscreen mode

In manifest.json:

{
  "content_scripts": [{
    "matches": ["<all_urls>"],
    "js": ["content.js"],
    "run_at": "document_start"
  }],
  "web_accessible_resources": [{
    "resources": ["vendor/ajax-hooker.iife.js"],
    "matches": ["<all_urls>"]
  }]
}
Enter fullscreen mode Exit fullscreen mode

API Reference

Method Description
AjaxInterceptor.getInstance() Get singleton instance
interceptor.inject(type?) Inject interception. type: 'xhr' / 'fetch' / omit for both
interceptor.uninject(type?) Remove interception, restore native objects
interceptor.hook(fn, type?) Register hook function. Optional type for specific interception
interceptor.unhook(fn?, type?) Remove hook. Omit fn to clear all hooks

Request Object Properties

Property Type Writable Description
type `'xhr' \ 'fetch'` No
method string Yes HTTP method
url string Yes Request URL
headers Headers Yes Request headers (standard Headers object)
data any Yes Request body
response (resp) => void Yes Response callback, fired when response arrives
onStreamChunk `(chunk) => string \ void` Yes
responseType string Yes XHR only
withCredentials boolean Yes XHR only
timeout number Yes XHR only

Response Object Properties

Property Applies To Writable Description
status Both Yes HTTP status code
statusText Both Yes Status text
headers Both No Response headers
finalUrl Both No Final URL (after redirects)
response XHR Yes Raw response
responseText XHR Yes Text response
responseXML XHR Yes XML response
json Fetch Yes JSON data
text Fetch Yes Text data
arrayBuffer Fetch Yes ArrayBuffer data
blob Fetch Yes Blob data
formData Fetch Yes FormData data
ok Fetch No Success status (2xx)
redirected Fetch No Whether redirected

How It Works Under the Hood

For those interested in the implementation, here's a brief overview:

XHR Interception

Uses ES6 Proxy to wrap native XMLHttpRequest instances:

new XMLHttpRequest()
  → proxyXhr() constructor
    → creates real xhr instance
    → returns Proxy(xhr, handler)
Enter fullscreen mode Exit fullscreen mode

The Proxy's get trap intercepts property access:

  • Method calls (open/send/setRequestHeader): Executes hook logic before/after calling native methods
  • Response properties (response/responseText/status): Returns potentially modified values after response processing
  • Event listeners (addEventListener/removeEventListener): Wraps response event listeners to ensure response processing runs first

Key design decisions:

  • Uses Symbol to attach state to instances, avoiding property name collisions
  • If hooks modify url or method, automatically reopens (re-calls native open)
  • responseProcessor has an idempotency guard — even if both onload and onreadystatechange fire, it only executes once

Fetch Interception

Replaces global window.fetch with a proxy function:

fetch(url, options)
  → proxyFetch()
    → normalize request parameters
    → execute hook chain
    → call native fetch
    → wrap Response with Proxy
Enter fullscreen mode Exit fullscreen mode

Fetch interception is more complex because:

  • fetch() supports three calling forms (string / URL / Request object), requiring unified normalization
  • Uses a sourceMap to track where each property originates (Request object / options / defaults), ensuring precise request reconstruction
  • Response handling splits into two paths: normal responses (parallel parsing of 5 formats) and streaming responses (TransformStream pipeline)

Why Proxy Instead of Prototype Patching?

The common approach to XHR interception is modifying methods on XMLHttpRequest.prototype. ajax-hooker uses Proxy because:

  1. Finer-grained control: Proxy intercepts property reads (get) and writes (set), not just method calls
  2. No prototype pollution: Each instance is independently proxied, no interference between instances
  3. Response property interception: response/responseText are read-only properties — prototype patching can't intercept their getters, but Proxy's get trap can
  4. instanceof compatibility: copyNativePropsAndPrototype ensures xhr instanceof XMLHttpRequest still returns true

Comparison with Other Solutions

Feature ajax-hooker Mock Service Worker (MSW) axios interceptors
Intercept XHR Yes Yes (Service Worker) axios only
Intercept Fetch Yes Yes (Service Worker) No
Modify responses Yes (direct mutation) Yes (handler-based) Yes (axios only)
Streaming responses Yes No No
Runtime dependencies 0 msw + worker file Built into axios
Use case Runtime interception Testing/dev mocking Within axios projects
Userscripts/extensions Ideal Not suitable Not suitable
Intercept third-party code Yes Yes No

Project Links


Final Words

ajax-hooker is under active development, with upcoming features like EventSource (SSE) interception on the roadmap.

If you run into any issues, feel free to open an issue on GitHub. And if you find this library useful, please consider giving it a Star on GitHub — it means a lot to open-source maintainers and helps more developers discover the project. Thanks!

Top comments (0)