xhr-inject
v0.1.1
Published
Plugin-based XHR interceptor — header injection, mock responses, queue concurrency, and transparent elevation flows (2FA / transaction signing).
Maintainers
Readme
xhr-hook
A plugin-based XMLHttpRequest interceptor for the browser. Patches window.XMLHttpRequest non-destructively so it chains safely with AppDynamics, Google Analytics, and any other tools that have already patched XHR.
npm install xhr-hookFeatures
- Plugin pipeline —
beforeRequest/afterResponse/onErrorhooks, each async, run in registration order - Header injection — mutate or add headers before every request
- Mock responses — short-circuit the network entirely from a plugin (great for testing / feature flags)
- Request queue — configurable concurrency, priority, and queue timeout
- Promise API —
interceptor.fetch()returnsPromise<ResponseData>, drop-in as a react-queryqueryFn - Elevation flows — automatic 2FA step-up and transaction signing via the optional elevation plugin
- Non-conflicting — subclass strategy, never mutates
XMLHttpRequest.prototype; compatible with Axios, AppDynamics, GA, and other XHR patchers
Quick start
import { XHRInterceptor } from 'xhr-hook';
const interceptor = new XHRInterceptor();
interceptor.use({
name: 'auth',
beforeRequest(config) {
config.headers['authorization'] = `Bearer ${getToken()}`;
return config;
},
});
interceptor.install(); // replaces window.XMLHttpRequestAfter install() every XHR fired by the page — including those from Axios, fetch polyfills, and third-party SDKs — flows through the plugin pipeline.
interceptor.fetch()
A Promise-based API compatible with react-query:
// react-query
useQuery({
queryKey: ['users'],
queryFn: () => interceptor.fetch({ url: '/api/users' }).then(r => r.body),
});
// plain async/await
const { body, status, duration } = await interceptor.fetch({
url: '/api/data',
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ key: 'value' }),
});Axios custom adapter
Route an Axios instance through the interceptor so every Axios call benefits from the plugin pipeline (including transparent elevation):
const axiosInstance = axios.create({
adapter(config) {
return interceptor.fetch({
url: config.url,
method: (config.method || 'GET').toUpperCase(),
headers: config.headers ?? {},
body: config.data ?? null,
}).then(r => ({
data: r.body, status: r.status, statusText: r.statusText,
headers: r.headers, config, request: null,
}));
},
});Plugin API
interface XHRPlugin {
name: string;
beforeRequest?(config: RequestConfig): RequestConfig | MockResponse | void | Promise<...>;
afterResponse?(response: ResponseData, config: RequestConfig): ResponseData | void | Promise<...>;
onError?(error: XHRError, config: RequestConfig): ResponseData | void | Promise<...>;
}| Hook | Purpose |
|------|---------|
| beforeRequest | Mutate URL, method, headers, body, timeout. Return a MockResponse to skip the network entirely. |
| afterResponse | Inspect or transform the response. Return a new ResponseData to replace it. |
| onError | Recover from network / timeout / abort errors by returning a synthetic ResponseData. |
Mock responses
import { mockResponse } from 'xhr-hook';
interceptor.use({
name: 'feature-flag-mock',
beforeRequest(config) {
if (config.url.includes('/api/beta')) {
return mockResponse({ status: 200, body: { enabled: true }, delay: 50 });
}
},
});Queue concurrency
const interceptor = new XHRInterceptor({
queue: {
concurrency: 2, // max 2 in-flight at once
timeout: 10_000, // reject after 10 s in queue
priorityFn: (a, b) => (b.config.metadata.priority ?? 0) - (a.config.metadata.priority ?? 0),
},
});Elevation plugin
The optional elevation plugin handles 2FA step-up auth and transaction signing transparently. When a response signals that elevation is required, the plugin runs the challenge, patches the headers, and retries — the original caller receives the final successful response as if nothing happened.
import { XHRInterceptor } from 'xhr-hook';
import {
createElevationPlugin,
createAuthElevationHandler,
createTransactionElevationHandler,
createTokenStore,
} from 'xhr-hook/elevation';2FA / step-up auth
const tokenStore = createTokenStore(localStorage.getItem('token') ?? '');
// Inject current token on every request
interceptor.use({
name: 'token-injector',
beforeRequest(config) {
config.headers['authorization'] = `Bearer ${tokenStore.get()}`;
return config;
},
});
const authHandler = createAuthElevationHandler({
// Detect the elevation signal however your API expresses it
matches: (r) => r.body?.code === 'ELEVATION_REQUIRED' && r.body?.type === '2FA',
// Show your 2FA UI, resolve with the new token string
elevate: async () => {
const { token } = await show2FAModal();
return token;
},
// Persist the refreshed token so queued requests pick it up automatically
onTokenRefreshed: (token) => {
tokenStore.set(token);
localStorage.setItem('token', token);
},
});
interceptor.use(createElevationPlugin(interceptor, {
handlers: [authHandler],
retryTimeout: 0, // no hard timeout on retries — user may take time in the modal
}));Concurrent requests that all hit the elevation signal are deduplicated: elevate() is called exactly once, and all waiting requests retry with the same new token.
Transaction signing
const txHandler = createTransactionElevationHandler({
matches: (r) => r.body?.code === 'ELEVATION_REQUIRED' && r.body?.type === 'TRANSACTION',
// Called independently per request — each gets its own one-time token
sign: async (config) => {
const { 'x-tx-token': token } = await callSigningService(config);
return { 'x-tx-token': token };
},
});retryTimeout
Override the XHR timeout specifically for retry requests. Applied in beforeRequest after send() has snapshotted the caller's timeout, so it reliably overrides any Axios / library-set timeout:
createElevationPlugin(interceptor, {
handlers: [...],
retryTimeout: 0, // 0 = no timeout on retries
// retryTimeout: 5000 // or a specific ms value
})API reference
new XHRInterceptor(options?)
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| queue.concurrency | number | Infinity | Max simultaneous in-flight requests |
| queue.timeout | number | — | Max ms a request may wait in queue |
| queue.priorityFn | function | — | Custom sort for the queue |
| rejectOnHttpError | boolean | true | Throw XHRError for HTTP >= 400 |
interceptor.use(plugin) / .remove(name)
Register or remove a plugin. Plugins run in registration order.
interceptor.install() / .uninstall()
Replace / restore window.XMLHttpRequest. Captures whatever window.XMLHttpRequest is at call time, so it composes correctly with other patchers.
interceptor.fetch(partial)
Fire a request through the interceptor and return Promise<ResponseData>.
mockResponse(partial)
Helper to create a MockResponse from a plugin's beforeRequest.
Development
bun run test # run all tests (vitest, jsdom)
bun run build # build dist/ for npm
bun run demo # start demo server at http://localhost:3000License
MIT
