A lightweight HTTP recording and replay library for Bun + TypeScript, designed to simplify and accelerate integration tests by recording real fetch responses and replaying them in future test runs.
api-replay
helps you:
- Record HTTP API calls made during integration tests
- Replay those responses later, eliminating the need for live API access
- Speed up test runs and improve reliability
- Easily customize what parts of a request are considered for matching
import { replayAPI } from 'api-replay';
const startTime = performance.now();
await replayAPI.start('my-first-call-recording',);
// You long-running call here
const response = await fetch("https://dummyjson.com/test?delay=1500");
const endTime = performance.now();
const timeTaken = endTime - startTime;
console.log(`Time taken: ${timeTaken.toFixed(1)}ms`);
await replayAPI.done();
Run using bun run file.ts
. The second time you run this, it will be very fast since it replays the recorded response instead of making a live network call.
$ bun run basic-test.ts
Time taken: 1829.2ms
$ bun run basic-test.ts
Time taken: 2.2ms
test('can read orders for a range of dates given day', async () => {
await replayAPI.start('shopify client/can read orders for a range of dates given day', {
debug: true, // Enable logging for this test
include: {
headers: ['Authorization'], // Include Authorization header in matching to support multiple users
},
exclude: {
headers: ['Cookie'], // Ignore Cookie header to avoid issues with session-specific data
query: ['token'], // Ignore token query param to avoid issues with session-specific data
},
recordingsDir: 'myapirecordings', // Custom directory for storing recordings
});
const client = new ShopifyClient();
const orders = await client.getOrders('2024-08-26', '2024-08-28');
expect(orders.length).toEqual(42);
await replayAPI.done();
});
bun add -d api-replay
npm package: api-replay
Requirements:
- Bun >=1.1.0
This library uses native Bun APIs (Bun.write
, Bun.file
, etc.) and requires Bun 1.1.0 or later due to improvements in fetch and Response handling.
Starts intercepting and recording or replaying API calls made via fetch.
testName
is used to determine the filename for storing or reading recordings- Example:
'shopify client/can read orders for a range of dates given day'
- Becomes:
./.api-replay/shopify-client--can-read-orders-for-a-range-of-dates-given-day.json
config
(optional): controls which parts of the request are included or excluded from match comparison
Stops interception and saves recordings (if in record mode).
Logging is disabled by default for clean test output. Enable it via:
Option 1: Environment Variable
APIREPLAYLOGS=1 bun test
Option 2: Config Option
await replayAPI.start('test-name', { debug: true });
When no matching recording is found during replay, api-replay
provides detailed error information to help you understand why the match failed. This includes:
- What was searched for: The exact request details (method, URL, headers, body)
- What's available: All recorded calls that could potentially match
- Why it didn't match: By comparing search criteria with available recordings
- HTTP method (GET, POST, etc.)
- URL path (/orders, /posts/1)
- Query string (all keys)
- Headers
- Failed responses (4xx, 5xx status codes)
- Response headers (not recorded by default)
If a request doesn't match any recorded calls during replay, api-replay
throws an error with detailed information about:
- The request that was being searched for
- All available recorded calls
- The specific differences that caused the mismatch
This helps you quickly identify why the match failed and configure the appropriate exclusions or inclusions.
type MatchingConfig = {
include?: {
headers?: string[];
};
exclude?: {
headers?: string[];
query?: string[];
body?: boolean;
};
debug?: boolean; // Enable logging for this session
recordingsDir?: string; // Directory for storing recordings (default: '.api-replay')
recordFailedResponses?: boolean; // Record and match failed responses (4xx, 5xx) (default: false)
recordResponseHeaders?: string[] | "*"; // Response headers to record (default: none)
};
Examples:
// Match specific headers
{ include: { headers: ['Authorization'] } }
// Ignore some volatile query params
{ exclude: { query: ['timestamp', 'token'] } }
// Don't match on body
{ exclude: { body: true } }
// Enable debug logging for this test
{ debug: true }
// Use custom recordings directory
{ recordingsDir: 'my-recordings' }
// Use absolute path for recordings
{ recordingsDir: '/tmp/api-recordings' }
// Record and match failed responses
{ recordFailedResponses: true }
// Record all response headers
{ recordResponseHeaders: "*" }
// Record specific response headers only
{ recordResponseHeaders: ['content-type', 'x-api-version'] }
// Combine options
{
debug: true,
recordingsDir: 'custom-recordings',
recordFailedResponses: true,
recordResponseHeaders: ['content-type'],
exclude: { headers: ['user-agent'], query: ['timestamp'] }
}
By default, api-replay
does not record response headers to keep recordings lightweight and avoid issues with volatile headers like timestamps or server-specific values.
await replayAPI.start('my-test');
const response = await fetch('/api/data');
// Response headers are not recorded in the JSON file
await replayAPI.start('my-test', {
recordResponseHeaders: "*"
});
const response = await fetch('/api/data');
// All response headers (content-type, date, server, etc.) are recorded
await replayAPI.start('my-test', {
recordResponseHeaders: ['content-type', 'x-api-version', 'cache-control']
});
const response = await fetch('/api/data');
// Only content-type, x-api-version, and cache-control headers are recorded
Note: Response header names are case-insensitive. Headers are stored in lowercase in the recording files. See https://datatracker.ietf.org/doc/html/rfc7230#section-3.2
By default, api-replay
only records and matches successful responses (2xx and 3xx status codes). Failed responses (4xx and 5xx) are ignored to improve test reliability and avoid brittle tests that depend on specific error conditions.
await replayAPI.start('my-test');
// These responses are recorded and matched:
await fetch('/api/users/1'); // 200 OK β
await fetch('/api/redirect'); // 301 Moved β
// These responses are ignored:
await fetch('/api/nonexistent'); // 404 Not Found β
await fetch('/api/server-error'); // 500 Internal Server Error β
If you need to test error handling scenarios, enable recordFailedResponses
:
await replayAPI.start('error-handling-test', {
recordFailedResponses: true
});
// Now all responses are recorded and matched:
await fetch('/api/users/1'); // 200 OK β
await fetch('/api/nonexistent'); // 404 Not Found β
await fetch('/api/server-error'); // 500 Internal Server Error β
- By default, recordings are saved under:
./.api-replay/
- Directory can be customized using the
recordingsDir
configuration option - Filename is derived from the test name by replacing slashes and spaces:
shopify client/can read orders for a range of dates given day
=>
shopify-client--can-read-orders-for-a-range-of-dates-given-day.json
Each recording file is a pretty-printed .json
with this structure:
Note: Response headers are empty by default. Use recordResponseHeaders
configuration to record specific headers or all headers.
{
"meta": {
"recordedAt": "2025-07-17T12:34:56Z",
"testName": "shopify client/can read orders for a range of dates given day",
"replayAPIVersion": "1.0.0"
},
"calls": [
{
"request": {
"method": "GET",
"url": "https://api.example.com/orders?startDate=2022-01-01",
"headers": {
"Authorization": "Bearer xyz"
},
"body": null
},
"response": {
"status": 200,
"headers": {},
"body": "[{\"orderId\":1234}]"
}
}
]
}
"No matching recorded call found" Error
When you see this error, the detailed output will show you exactly what was searched for and what's available. Common solutions:
- Headers differ: Use
exclude: { headers: ['authorization', 'user-agent'] }
to ignore volatile headers - Query params differ: Use
exclude: { query: ['timestamp', 'token'] }
to ignore dynamic parameters - Body differs: Use
exclude: { body: true }
if the body content varies between runs - Method/URL differs: Check that your request matches exactly what was recorded
Example Fix:
// If you see authorization headers differ in the error output:
await replayAPI.start('my-test', {
exclude: {
headers: ['authorization', 'x-api-key'],
query: ['timestamp']
}
});
Enable debug logging to see what's happening:
await replayAPI.start('my-test', { debug: true });
// or
APIREPLAYLOGS=1 bun test
Recorded headers and bodies are stored in plaintext. Be careful when recording:
- Authorization
- Cookies
- Personally identifiable information (PII)
Future improvements (not in scope of v1):
- Header redaction or masking
- Expiration of recordings
- Request/response transformers
- Snapshot diffing and versioning
Contributions are welcome! Please feel free to submit a Pull Request.
- Clone the repository
- Install dependencies:
bun install
- Run tests:
bun test
- Run type checking:
bun run typecheck
- Build the project:
bun run build
This project uses:
- ESLint for code linting
- Prettier for code formatting
- Husky for pre-commit hooks
- TypeScript for type safety
All contributions should pass the existing test suite and maintain 100% type coverage.
MIT