@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.
Files changed (3) hide show
  1. package/README.md +36 -9
  2. package/dist/index.js +127 -10
  3. 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 | Description | Default |
80
- | ---------------------------- | -------------------------------------------------------------- | --------- |
81
- | `-t, --target <url>` | Target API URL **(required)** | — |
82
- | `-m, --mode <mode>` | Operation mode: `record`, `replay`, or `hybrid` | `replay` |
83
- | `-p, --port <number>` | Local server port | `8080` |
84
- | `-d, --dir <path>` | Directory to save tapes | `./tapes` |
85
- | `--record-on-miss <boolean>` | In hybrid mode, save upstream response when tape is missing | `true` |
86
- | `--redact-header <headers>` | Comma-separated response header names to redact in saved tapes | — |
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 getTapeKey = (req) => {
62
- const key = `${req.method}|${req.url}`;
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
- const tapeKey = getTapeKey(req);
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.2.0").action((options) => {
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.2.0");
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);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laphilosophia/api-tape",
3
- "version": "1.2.1",
3
+ "version": "1.4.0",
4
4
  "description": "Record and Replay HTTP API responses for offline development",
5
5
  "main": "dist/index.js",
6
6
  "bin": {