@laphilosophia/api-tape 1.2.1 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -9
- package/dist/index.js +127 -10
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -11,10 +11,12 @@ API Tape is a zero-config CLI tool that acts as a transparent HTTP proxy. It rec
|
|
|
11
11
|
- **Hybrid Mode** — Replays cached tapes, falls back to upstream on cache miss
|
|
12
12
|
- **Tape Management Commands** — List, inspect, clear, and prune tapes from CLI
|
|
13
13
|
- **Header Redaction** — Mask sensitive response headers before writing tapes
|
|
14
|
+
- **Runtime Metrics** — Periodic and shutdown stats for replay hit/miss, upstream calls, and latency
|
|
14
15
|
- **Zero Config** — Works out of the box with sensible defaults
|
|
15
16
|
- **Binary Safe** — Handles images, compressed responses, and any content type
|
|
16
17
|
- **Replay Header** — Responses include `X-Api-Tape: Replayed` for easy debugging
|
|
17
18
|
- **Versioned Tape Schema** — Each tape includes `schemaVersion` for compatibility checks
|
|
19
|
+
- **Match Strategies** — `exact` and `normalized` matching for better replay hit rates
|
|
18
20
|
|
|
19
21
|
## Installation
|
|
20
22
|
|
|
@@ -76,14 +78,34 @@ tape --target "https://jsonplaceholder.typicode.com" --mode hybrid --record-on-m
|
|
|
76
78
|
|
|
77
79
|
Both legacy mode (`tape --target ...`) and explicit serve command (`tape serve --target ...`) are supported.
|
|
78
80
|
|
|
79
|
-
| Option
|
|
80
|
-
|
|
|
81
|
-
| `-t, --target <url>`
|
|
82
|
-
| `-m, --mode <mode>`
|
|
83
|
-
| `-p, --port <number>`
|
|
84
|
-
| `-d, --dir <path>`
|
|
85
|
-
| `--record-on-miss <boolean>`
|
|
86
|
-
| `--redact-header <headers>`
|
|
81
|
+
| Option | Description | Default |
|
|
82
|
+
| ----------------------------- | -------------------------------------------------------------- | --------- |
|
|
83
|
+
| `-t, --target <url>` | Target API URL **(required)** | — |
|
|
84
|
+
| `-m, --mode <mode>` | Operation mode: `record`, `replay`, or `hybrid` | `replay` |
|
|
85
|
+
| `-p, --port <number>` | Local server port | `8080` |
|
|
86
|
+
| `-d, --dir <path>` | Directory to save tapes | `./tapes` |
|
|
87
|
+
| `--record-on-miss <boolean>` | In hybrid mode, save upstream response when tape is missing | `true` |
|
|
88
|
+
| `--redact-header <headers>` | Comma-separated response header names to redact in saved tapes | — |
|
|
89
|
+
| `--match-strategy <strategy>` | Tape matching strategy: `exact` or `normalized` | `exact` |
|
|
90
|
+
|
|
91
|
+
### Runtime stats
|
|
92
|
+
|
|
93
|
+
```bash
|
|
94
|
+
tape serve --target "https://jsonplaceholder.typicode.com" --mode hybrid --stats-interval 10
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
For machine-readable output:
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
tape serve --target "https://jsonplaceholder.typicode.com" --stats-interval 10 --stats-json
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
On shutdown, API Tape always prints a final summary (`FINAL_STATS`).
|
|
104
|
+
|
|
105
|
+
### Match strategy
|
|
106
|
+
|
|
107
|
+
- `exact` (default): hashes `METHOD|URL` as-is.
|
|
108
|
+
- `normalized`: sorts query params before hashing, so `/search?a=1&b=2` and `/search?b=2&a=1` map to the same tape.
|
|
87
109
|
|
|
88
110
|
### Tape management commands
|
|
89
111
|
|
|
@@ -104,7 +126,8 @@ Each tape is a JSON file named with an MD5 hash of `METHOD|URL`:
|
|
|
104
126
|
"meta": {
|
|
105
127
|
"url": "/todos/1",
|
|
106
128
|
"method": "GET",
|
|
107
|
-
"timestamp": "2026-01-14T19:12:39.000Z"
|
|
129
|
+
"timestamp": "2026-01-14T19:12:39.000Z",
|
|
130
|
+
"matchStrategy": "normalized"
|
|
108
131
|
},
|
|
109
132
|
"statusCode": 200,
|
|
110
133
|
"headers": { ... },
|
|
@@ -121,6 +144,10 @@ npm run build
|
|
|
121
144
|
npm test
|
|
122
145
|
```
|
|
123
146
|
|
|
147
|
+
## CI
|
|
148
|
+
|
|
149
|
+
A GitHub Actions workflow runs `npm test` on both Linux and Windows for pushes and pull requests.
|
|
150
|
+
|
|
124
151
|
## Use Cases
|
|
125
152
|
|
|
126
153
|
- **Offline Development** — Work without internet or VPN
|
package/dist/index.js
CHANGED
|
@@ -55,11 +55,47 @@ var parsePositiveInt = (value, label) => {
|
|
|
55
55
|
}
|
|
56
56
|
return parsed;
|
|
57
57
|
};
|
|
58
|
+
var parseNonNegativeInt = (value, label) => {
|
|
59
|
+
const parsed = parseInt(value, 10);
|
|
60
|
+
if (!Number.isInteger(parsed) || parsed < 0) {
|
|
61
|
+
throw new Error(`${label} must be a non-negative integer.`);
|
|
62
|
+
}
|
|
63
|
+
return parsed;
|
|
64
|
+
};
|
|
58
65
|
var parseCsv = (value) => value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
59
66
|
|
|
60
67
|
// src/index.ts
|
|
61
|
-
var
|
|
62
|
-
|
|
68
|
+
var normalizeUrl = (rawUrl) => {
|
|
69
|
+
if (!rawUrl) {
|
|
70
|
+
return "/";
|
|
71
|
+
}
|
|
72
|
+
const [pathname, query = ""] = rawUrl.split("?");
|
|
73
|
+
if (!query) {
|
|
74
|
+
return pathname || "/";
|
|
75
|
+
}
|
|
76
|
+
const params = new URLSearchParams(query);
|
|
77
|
+
const normalizedEntries = Array.from(params.entries()).sort(([aKey, aValue], [bKey, bValue]) => {
|
|
78
|
+
if (aKey === bKey) {
|
|
79
|
+
return aValue.localeCompare(bValue);
|
|
80
|
+
}
|
|
81
|
+
return aKey.localeCompare(bKey);
|
|
82
|
+
});
|
|
83
|
+
const normalized = new URLSearchParams();
|
|
84
|
+
normalizedEntries.forEach(([key, value]) => {
|
|
85
|
+
normalized.append(key, value);
|
|
86
|
+
});
|
|
87
|
+
const normalizedQuery = normalized.toString();
|
|
88
|
+
return normalizedQuery ? `${pathname || "/"}?${normalizedQuery}` : pathname || "/";
|
|
89
|
+
};
|
|
90
|
+
var buildRequestSignature = (req, matchStrategy) => {
|
|
91
|
+
const method = req.method || "GET";
|
|
92
|
+
if (matchStrategy === "normalized") {
|
|
93
|
+
return `${method}|${normalizeUrl(req.url)}`;
|
|
94
|
+
}
|
|
95
|
+
return `${method}|${req.url}`;
|
|
96
|
+
};
|
|
97
|
+
var getTapeKey = (req, matchStrategy) => {
|
|
98
|
+
const key = buildRequestSignature(req, matchStrategy);
|
|
63
99
|
return import_crypto.default.createHash("md5").update(key).digest("hex");
|
|
64
100
|
};
|
|
65
101
|
var readTape = (tapePath) => import_fs_extra.default.readJsonSync(tapePath);
|
|
@@ -82,12 +118,13 @@ var ensureSchemaCompatibility = (record, tapePath) => {
|
|
|
82
118
|
throw new Error(`Unsupported tape schema version at ${tapePath}: ${schemaVersion}`);
|
|
83
119
|
}
|
|
84
120
|
};
|
|
85
|
-
var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders) => ({
|
|
121
|
+
var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders, matchStrategy) => ({
|
|
86
122
|
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
87
123
|
meta: {
|
|
88
124
|
url: req.url,
|
|
89
125
|
method: req.method,
|
|
90
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
126
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
127
|
+
matchStrategy
|
|
91
128
|
},
|
|
92
129
|
statusCode: proxyRes.statusCode || 200,
|
|
93
130
|
headers: redactHeaders(
|
|
@@ -96,6 +133,25 @@ var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders) => ({
|
|
|
96
133
|
),
|
|
97
134
|
body: bodyBuffer.toString("base64")
|
|
98
135
|
});
|
|
136
|
+
var buildMetricsSnapshot = (metrics) => ({
|
|
137
|
+
totalRequests: metrics.totalRequests,
|
|
138
|
+
replayHits: metrics.replayHits,
|
|
139
|
+
replayMisses: metrics.replayMisses,
|
|
140
|
+
upstreamRequests: metrics.upstreamRequests,
|
|
141
|
+
upstreamErrors: metrics.upstreamErrors,
|
|
142
|
+
completedResponses: metrics.completedResponses,
|
|
143
|
+
averageLatencyMs: metrics.completedResponses === 0 ? 0 : Number((metrics.totalLatencyMs / metrics.completedResponses).toFixed(2))
|
|
144
|
+
});
|
|
145
|
+
var printMetrics = (metrics, asJson, prefix = "STATS") => {
|
|
146
|
+
const snapshot = buildMetricsSnapshot(metrics);
|
|
147
|
+
if (asJson) {
|
|
148
|
+
console.log(JSON.stringify({ event: prefix, ...snapshot }));
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
console.log(
|
|
152
|
+
`${timestamp()} ${import_chalk2.default.cyan(prefix)} total=${snapshot.totalRequests} replay_hit=${snapshot.replayHits} replay_miss=${snapshot.replayMisses} upstream=${snapshot.upstreamRequests} upstream_errors=${snapshot.upstreamErrors} avg_latency_ms=${snapshot.averageLatencyMs}`
|
|
153
|
+
);
|
|
154
|
+
};
|
|
99
155
|
var runServe = (opts) => {
|
|
100
156
|
const targetUrl = opts.target;
|
|
101
157
|
const port = parsePositiveInt(opts.port, "Port");
|
|
@@ -103,10 +159,19 @@ var runServe = (opts) => {
|
|
|
103
159
|
const tapesDir = import_path.default.resolve(opts.dir);
|
|
104
160
|
const recordOnMiss = opts.recordOnMiss;
|
|
105
161
|
const redactedHeaders = parseCsv(opts.redactHeader);
|
|
162
|
+
const statsIntervalSec = parseNonNegativeInt(opts.statsInterval, "stats-interval");
|
|
163
|
+
const statsJson = opts.statsJson;
|
|
164
|
+
const matchStrategy = opts.matchStrategy;
|
|
106
165
|
const validModes = ["record", "replay", "hybrid"];
|
|
107
166
|
if (!validModes.includes(mode)) {
|
|
108
167
|
throw new Error(`Invalid mode: ${mode}. Expected one of: ${validModes.join(", ")}`);
|
|
109
168
|
}
|
|
169
|
+
const validMatchStrategies = ["exact", "normalized"];
|
|
170
|
+
if (!validMatchStrategies.includes(matchStrategy)) {
|
|
171
|
+
throw new Error(
|
|
172
|
+
`Invalid match strategy: ${matchStrategy}. Expected one of: ${validMatchStrategies.join(", ")}`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
110
175
|
import_fs_extra.default.ensureDirSync(tapesDir);
|
|
111
176
|
const proxy = import_http_proxy.default.createProxyServer({
|
|
112
177
|
target: targetUrl,
|
|
@@ -114,6 +179,16 @@ var runServe = (opts) => {
|
|
|
114
179
|
selfHandleResponse: true
|
|
115
180
|
});
|
|
116
181
|
const recordByRequest = /* @__PURE__ */ new WeakMap();
|
|
182
|
+
const requestStartByResponse = /* @__PURE__ */ new WeakMap();
|
|
183
|
+
const metrics = {
|
|
184
|
+
totalRequests: 0,
|
|
185
|
+
replayHits: 0,
|
|
186
|
+
replayMisses: 0,
|
|
187
|
+
upstreamRequests: 0,
|
|
188
|
+
upstreamErrors: 0,
|
|
189
|
+
totalLatencyMs: 0,
|
|
190
|
+
completedResponses: 0
|
|
191
|
+
};
|
|
117
192
|
const replayTape = (req, res, tapePath) => {
|
|
118
193
|
if (!import_fs_extra.default.existsSync(tapePath)) {
|
|
119
194
|
return false;
|
|
@@ -127,6 +202,7 @@ var runServe = (opts) => {
|
|
|
127
202
|
res.setHeader("X-Api-Tape", "Replayed");
|
|
128
203
|
res.writeHead(tape.statusCode);
|
|
129
204
|
res.end(Buffer.from(tape.body, "base64"));
|
|
205
|
+
metrics.replayHits += 1;
|
|
130
206
|
console.log(`${timestamp()} ${import_chalk2.default.green("\u21BA REPLAY_HIT")} ${req.method} ${req.url}`);
|
|
131
207
|
return true;
|
|
132
208
|
} catch (error) {
|
|
@@ -142,19 +218,31 @@ var runServe = (opts) => {
|
|
|
142
218
|
};
|
|
143
219
|
const proxyRequest = (req, res, shouldRecord, logPrefix) => {
|
|
144
220
|
recordByRequest.set(req, shouldRecord);
|
|
221
|
+
metrics.upstreamRequests += 1;
|
|
145
222
|
console.log(`${timestamp()} ${logPrefix} ${req.method} ${req.url}`);
|
|
146
223
|
proxy.web(req, res, {}, (error) => {
|
|
224
|
+
metrics.upstreamErrors += 1;
|
|
147
225
|
console.error(import_chalk2.default.red("Proxy Error:"), error.message);
|
|
148
226
|
res.statusCode = 502;
|
|
149
227
|
res.end("Proxy Error");
|
|
150
228
|
});
|
|
151
229
|
};
|
|
152
230
|
const server = import_http.default.createServer((req, res) => {
|
|
153
|
-
|
|
231
|
+
metrics.totalRequests += 1;
|
|
232
|
+
requestStartByResponse.set(res, Date.now());
|
|
233
|
+
res.on("finish", () => {
|
|
234
|
+
const startedAt = requestStartByResponse.get(res);
|
|
235
|
+
if (typeof startedAt === "number") {
|
|
236
|
+
metrics.totalLatencyMs += Date.now() - startedAt;
|
|
237
|
+
metrics.completedResponses += 1;
|
|
238
|
+
}
|
|
239
|
+
});
|
|
240
|
+
const tapeKey = getTapeKey(req, matchStrategy);
|
|
154
241
|
const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
|
|
155
242
|
if (mode === "replay") {
|
|
156
243
|
const hit2 = replayTape(req, res, tapePath);
|
|
157
244
|
if (!hit2) {
|
|
245
|
+
metrics.replayMisses += 1;
|
|
158
246
|
console.log(`${timestamp()} ${import_chalk2.default.red("\u2718 REPLAY_MISS")} ${req.method} ${req.url}`);
|
|
159
247
|
res.statusCode = 404;
|
|
160
248
|
res.end(`Tape not found for: ${req.method} ${req.url}`);
|
|
@@ -169,6 +257,7 @@ var runServe = (opts) => {
|
|
|
169
257
|
if (hit) {
|
|
170
258
|
return;
|
|
171
259
|
}
|
|
260
|
+
metrics.replayMisses += 1;
|
|
172
261
|
console.log(`${timestamp()} ${import_chalk2.default.yellow("\u21E2 REPLAY_MISS")} ${req.method} ${req.url}`);
|
|
173
262
|
proxyRequest(
|
|
174
263
|
req,
|
|
@@ -184,8 +273,8 @@ var runServe = (opts) => {
|
|
|
184
273
|
const bodyBuffer = Buffer.concat(bodyChunks);
|
|
185
274
|
const shouldRecord = recordByRequest.get(req) ?? true;
|
|
186
275
|
if (shouldRecord) {
|
|
187
|
-
const tapeData = createTapeRecord(req, proxyRes, bodyBuffer, redactedHeaders);
|
|
188
|
-
const tapeKey = getTapeKey(req);
|
|
276
|
+
const tapeData = createTapeRecord(req, proxyRes, bodyBuffer, redactedHeaders, matchStrategy);
|
|
277
|
+
const tapeKey = getTapeKey(req, matchStrategy);
|
|
189
278
|
const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
|
|
190
279
|
import_fs_extra.default.writeJsonSync(tapePath, tapeData, { spaces: 2 });
|
|
191
280
|
console.log(`${timestamp()} ${import_chalk2.default.cyan("\u{1F4BE} SAVED")} ${req.method} ${req.url}`);
|
|
@@ -197,6 +286,27 @@ var runServe = (opts) => {
|
|
|
197
286
|
res.end(bodyBuffer);
|
|
198
287
|
});
|
|
199
288
|
});
|
|
289
|
+
let metricsTimer;
|
|
290
|
+
if (statsIntervalSec > 0) {
|
|
291
|
+
metricsTimer = setInterval(() => {
|
|
292
|
+
printMetrics(metrics, statsJson);
|
|
293
|
+
}, statsIntervalSec * 1e3);
|
|
294
|
+
}
|
|
295
|
+
const shutdown = (signal) => {
|
|
296
|
+
if (metricsTimer) {
|
|
297
|
+
clearInterval(metricsTimer);
|
|
298
|
+
metricsTimer = void 0;
|
|
299
|
+
}
|
|
300
|
+
printMetrics(metrics, statsJson, "FINAL_STATS");
|
|
301
|
+
server.close(() => {
|
|
302
|
+
process.exit(0);
|
|
303
|
+
});
|
|
304
|
+
setTimeout(() => {
|
|
305
|
+
process.exit(0);
|
|
306
|
+
}, 2e3).unref();
|
|
307
|
+
};
|
|
308
|
+
process.once("SIGINT", () => shutdown("SIGINT"));
|
|
309
|
+
process.once("SIGTERM", () => shutdown("SIGTERM"));
|
|
200
310
|
console.log(import_chalk2.default.bold(`
|
|
201
311
|
\u{1F4FC} API Tape Running`));
|
|
202
312
|
console.log(
|
|
@@ -211,6 +321,13 @@ var runServe = (opts) => {
|
|
|
211
321
|
if (redactedHeaders.length > 0) {
|
|
212
322
|
console.log(` ${import_chalk2.default.dim("Redact Headers:")} ${redactedHeaders.join(", ")}`);
|
|
213
323
|
}
|
|
324
|
+
if (statsIntervalSec > 0) {
|
|
325
|
+
console.log(` ${import_chalk2.default.dim("Stats Every:")} ${statsIntervalSec}s`);
|
|
326
|
+
}
|
|
327
|
+
if (statsJson) {
|
|
328
|
+
console.log(` ${import_chalk2.default.dim("Stats Format:")} json`);
|
|
329
|
+
}
|
|
330
|
+
console.log(` ${import_chalk2.default.dim("Match Strategy:")} ${matchStrategy}`);
|
|
214
331
|
console.log("");
|
|
215
332
|
server.listen(port);
|
|
216
333
|
};
|
|
@@ -288,19 +405,19 @@ var addServeOptions = (command) => command.requiredOption("-t, --target <url>",
|
|
|
288
405
|
"--redact-header <headers>",
|
|
289
406
|
"Comma-separated response header names to redact before saving",
|
|
290
407
|
""
|
|
291
|
-
);
|
|
408
|
+
).option("--stats-interval <seconds>", "Emit runtime stats every N seconds (0 disables)", "0").option("--stats-json", "Emit stats in JSON format", false).option("--match-strategy <strategy>", "Tape matching strategy: exact or normalized", "exact");
|
|
292
409
|
var run = () => {
|
|
293
410
|
const argv = process.argv;
|
|
294
411
|
const hasSubCommand = argv.length > 2 && ["serve", "tape"].includes(argv[2]);
|
|
295
412
|
if (!hasSubCommand) {
|
|
296
|
-
const legacy = addServeOptions(new import_commander.Command()).name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.
|
|
413
|
+
const legacy = addServeOptions(new import_commander.Command()).name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.3.0").action((options) => {
|
|
297
414
|
runServe(options);
|
|
298
415
|
});
|
|
299
416
|
legacy.parse(argv);
|
|
300
417
|
return;
|
|
301
418
|
}
|
|
302
419
|
const program = new import_commander.Command();
|
|
303
|
-
program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.
|
|
420
|
+
program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.3.0");
|
|
304
421
|
addServeOptions(program.command("serve").description("Run API Tape proxy server")).action(
|
|
305
422
|
(options) => {
|
|
306
423
|
runServe(options);
|