Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file.
The format is based on Keep a Changelog,
and this project adheres to Semantic Versioning.

## [1.0.2] - 2026-05-03
### Added
- URL path prefix mapping for cross-environment replay: record on staging (e.g. `/microservice1/api/users`), replay locally (e.g. `/api/users`) using the new **URL Mappings** field in the Replay tab.
- Mappings are persisted with each recording's replay options and restored on selection.
- README workflow documentation for the central-record / local-replay use case.

## [1.0.1] - 2026-04-26

### Added
Expand Down
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ It is built for a low-overhead workflow: record once, export JSON fixtures, and

![API Replay popup preview](img.png)

![API Replay overview — before and after](apireplay-overview.png)

## Why teams use it

- Keep working when central APIs are slow, down, or changing.
Expand Down Expand Up @@ -39,7 +41,19 @@ Tip: Use `Alt+Shift+R` to toggle recording quickly.

## Common workflows

### A) Record in shared environment, replay locally
### A) Record in shared environment, replay locally (with URL path mapping)

A common use case is recording on a central/staging environment where APIs are served under microservice-prefixed paths (e.g. `/microservice1/api/users`), then replaying locally where the same API is served at a shorter path (e.g. `/api/users`).

To handle this:
1. Record normally on the central environment — requests are stored as-is.
2. Before starting replay locally, enter a URL mapping in the **URL Mappings** field in the Replay tab:
```
/microservice1/api -> /api
```
3. During replay, incoming requests to `/api/users` will be matched against the recorded `/microservice1/api/users` entry.

Multiple mappings are supported (one per line). The first matching prefix wins. Mappings are saved with the recording's replay options and restored when you select the recording again.

Capture traffic from a central/staging environment, export the recording JSON, commit it, and reuse it locally or in CI.

Expand All @@ -66,6 +80,7 @@ API Replay currently documents and supports client/network request mocking flows
- Search requests by URL/method/status.
- Enable/disable replay per request.
- Simulate replay latency (`latencyMs` or range).
- Map recorded URL path prefixes to different prefixes during replay (e.g. `/microservice1/api` → `/api`).
- View replay stats (matched/unmatched/hit counts).

## Development commands
Expand Down
Binary file added apireplay-overview.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "apireplay",
"version": "1.0.1",
"version": "1.0.2",
"private": true,
"type": "module",
"scripts": {
Expand Down
27 changes: 23 additions & 4 deletions src/background/replayer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,25 @@
import type { StateStore } from './state-store';
import { getRecordingByName } from '../shared/storage';
import type { UrlMapping } from '../shared/recording';

function getPathname(url: string): string {
return new URL(url).pathname;
}

function applyUrlMappings(pathname: string, mappings: UrlMapping[] | undefined): string {
if (!mappings || mappings.length === 0) {
return pathname;
}
for (const mapping of mappings) {
const from = mapping.from.startsWith('/') ? mapping.from : '/' + mapping.from;
const to = mapping.to.startsWith('/') ? mapping.to : '/' + mapping.to;
if (pathname.startsWith(from)) {
return to + pathname.slice(from.length);
}
}
return pathname;
}

type ReplayerEventParams = {
request?: { url?: string };
interceptionId?: string;
Expand Down Expand Up @@ -156,13 +171,17 @@ export async function onReplayerEvent(store: StateStore, tabId: number, message:
return;
}

let matched = requestValues.find((item) => getPathname(item.url) === getPathname(requestUrl));
const replayOptionsData = await chrome.storage.session.get('replayOptions');
const urlMappings = replayOptionsData.replayOptions?.urlMappings;

const incomingPathname = applyUrlMappings(getPathname(requestUrl), urlMappings);

let matched = requestValues.find((item) => getPathname(item.url) === incomingPathname);

if (!matched && state.fallbackMatchingEnabled) {
matched = requestValues.find((item) => {
const candidatePath = getPathname(item.url);
const targetPath = getPathname(requestUrl);
return candidatePath.split('/').filter(Boolean).length === targetPath.split('/').filter(Boolean).length;
return candidatePath.split('/').filter(Boolean).length === incomingPathname.split('/').filter(Boolean).length;
});
}

Expand Down Expand Up @@ -224,4 +243,4 @@ export async function onReplayerEvent(store: StateStore, tabId: number, message:
)
});
}
}
}
4 changes: 4 additions & 0 deletions src/popup.html
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,10 @@ <h3 class="text-base font-semibold">Select Recording</h3>
<input id="latencyMs" type="number" min="0" placeholder="Latency ms" class="p-1 text-sm border rounded dark:bg-gray-700" title="Optional fixed delay in milliseconds before each mocked response is returned">
<input id="latencyRange" type="text" placeholder="Range: min,max" class="p-1 text-sm border rounded dark:bg-gray-700" title="Optional random delay range in milliseconds as min,max (for example: 100,500)">
</div>
<div class="mt-2">
<label class="block text-xs font-semibold mb-1" for="urlMappings" title="Map recorded URL prefixes to different prefixes during replay. One mapping per line in the format: /from -> /to (for example: /microservice1/api -> /api)">URL Mappings (one per line: /from -&gt; /to)</label>
<textarea id="urlMappings" rows="3" placeholder="/microservice1/api -> /api" class="w-full p-1 text-sm border rounded dark:bg-gray-700 font-mono" title="Map recorded URL path prefixes to replay path prefixes. Format: /recorded-prefix -> /replay-prefix. Example: /microservice1/api -> /api"></textarea>
</div>
</div>

<div id="replayStatsPanel" class="mb-4 p-2 rounded bg-gray-200 dark:bg-gray-700 text-xs hidden"></div>
Expand Down
26 changes: 25 additions & 1 deletion src/popup/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ document.addEventListener('DOMContentLoaded', () => {
const replayStatsPanel = document.getElementById('replayStatsPanel');
const latencyMsInput = document.getElementById('latencyMs');
const latencyRangeInput = document.getElementById('latencyRange');
const urlMappingsInput = document.getElementById('urlMappings') as HTMLTextAreaElement | null;
const recordingSelect = document.getElementById('recordingSelect');
const deleteRecordBtn = document.getElementById('deleteRecord');
const removeAllRecordingsBtn = document.getElementById('removeAllRecordings');
Expand Down Expand Up @@ -233,9 +234,19 @@ document.addEventListener('DOMContentLoaded', () => {
.split(',')
.map((entry) => Number(entry.trim()))
.filter((entry) => Number.isFinite(entry));
const urlMappings = (urlMappingsInput?.value || '')
.split('\n')
.map((line) => line.trim())
.filter((line) => line.includes('->'))
.map((line) => {
const [from, to] = line.split('->').map((s) => s.trim());
return { from, to };
})
.filter((m) => m.from && m.to);
const options = {
latencyMs: latencyMsValue > 0 ? latencyMsValue : undefined,
latencyRange: latencyRangeValue.length === 2 ? [latencyRangeValue[0], latencyRangeValue[1]] : undefined
latencyRange: latencyRangeValue.length === 2 ? [latencyRangeValue[0], latencyRangeValue[1]] : undefined,
urlMappings: urlMappings.length > 0 ? urlMappings : undefined
};
void updateReplayOptions(name, options);
chrome.runtime.sendMessage({ action: 'startReplaying', name, fallbackMatching, options }, (response) => {
Expand Down Expand Up @@ -309,6 +320,7 @@ document.addEventListener('DOMContentLoaded', () => {
recordingSelect.addEventListener('input', () => {
updateButtonStates();
updateApiPreview();
loadReplayOptionsIntoUI(recordingSelect.value);
chrome.storage.local.set({ 'lastUsedRecord': recordingSelect.value });
});

Expand Down Expand Up @@ -483,6 +495,18 @@ document.addEventListener('DOMContentLoaded', () => {
);
}

function loadReplayOptionsIntoUI(name: string) {
if (!name) return;
void getRecording(name).then((recording) => {
if (!recording) return;
const opts = recording.replayOptions;
if (latencyMsInput) (latencyMsInput as HTMLInputElement).value = opts?.latencyMs != null ? String(opts.latencyMs) : '';
if (latencyRangeInput) (latencyRangeInput as HTMLInputElement).value = opts?.latencyRange ? opts.latencyRange.join(',') : '';
if (fallbackMatchingCheckbox) (fallbackMatchingCheckbox as HTMLInputElement).checked = opts?.fallbackMatching ?? false;
if (urlMappingsInput) urlMappingsInput.value = opts?.urlMappings ? opts.urlMappings.map((m) => `${m.from} -> ${m.to}`).join('\n') : '';
});
}

function updateApiPreview() {
const name = recordingSelect.value;
if (name) {
Expand Down
4 changes: 2 additions & 2 deletions src/popup/services/storage.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { normalizeRecording } from '../../shared/schema';
import type { Preset, Recording, UserSettings } from '../../shared/recording';
import type { Preset, Recording, UserSettings, UrlMapping } from '../../shared/recording';
import {
getRecordingByName,
getRecordingsStore,
Expand Down Expand Up @@ -245,7 +245,7 @@ export async function updateRecordingRequestSettings(

export async function updateReplayOptions(
recordingName: string,
options: { latencyMs?: number; latencyRange?: [number, number] }
options: { latencyMs?: number; latencyRange?: [number, number]; urlMappings?: UrlMapping[] }
): Promise<boolean> {
const recording = await getRecordingByName(recordingName);
if (!recording) {
Expand Down
6 changes: 6 additions & 0 deletions src/shared/recording.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,10 +14,16 @@ export interface RecordedRequest {
enabled?: boolean;
}

export interface UrlMapping {
from: string;
to: string;
}

export interface ReplayOptions {
fallbackMatching?: boolean;
latencyMs?: number;
latencyRange?: [number, number];
urlMappings?: UrlMapping[];
}

export interface RecordingMetadata {
Expand Down
Loading