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-hookerhandles 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
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 };
}
};
});
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/');
}
});
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'
);
}
});
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';
}
};
});
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`);
}
};
});
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();
});
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);
};
}
});
Auto-detected streaming Content-Types:
-
text/event-stream(SSE) application/x-ndjsonapplication/stream+jsonapplication/jsonlapplication/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
};
}
});
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();
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>"]
}]
}
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)
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
Symbolto attach state to instances, avoiding property name collisions - If hooks modify
urlormethod, automatically reopens (re-calls nativeopen) -
responseProcessorhas an idempotency guard — even if bothonloadandonreadystatechangefire, 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
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:
-
Finer-grained control: Proxy intercepts property reads (
get) and writes (set), not just method calls - No prototype pollution: Each instance is independently proxied, no interference between instances
-
Response property interception:
response/responseTextare read-only properties — prototype patching can't intercept their getters, but Proxy'sgettrap can -
instanceof compatibility:
copyNativePropsAndPrototypeensuresxhr instanceof XMLHttpRequeststill returnstrue
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
- GitHub: https://github.com/Arktomson/ajaxInterceptor
- npm: https://www.npmjs.com/package/ajax-hooker
- CDN:
https://unpkg.com/ajax-hooker/https://cdn.jsdelivr.net/npm/ajax-hooker - License: MIT
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)