@laphilosophia/api-tape 1.2.0 β†’ 1.3.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 +33 -18
  2. package/dist/index.js +82 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -1,22 +1,23 @@
1
- # πŸ“Ό API Tape
1
+ # API Tape
2
2
 
3
3
  **Record and Replay HTTP API responses for offline development.**
4
4
 
5
5
  API Tape is a zero-config CLI tool that acts as a transparent HTTP proxy. It records API responses to local JSON files ("tapes") and replays them instantlyβ€”perfect for offline development, flaky API testing, and reproducible demos.
6
6
 
7
- ## ✨ Features
7
+ ## Features
8
8
 
9
- - 🎬 **Record Mode** β€” Proxies requests to your target API and saves responses
10
- - πŸ”„ **Replay Mode** β€” Serves cached responses instantly from disk
11
- - πŸ”€ **Hybrid Mode** β€” Replays cached tapes, falls back to upstream on cache miss
12
- - 🧰 **Tape Management Commands** β€” List, inspect, clear, and prune tapes from CLI
13
- - πŸ” **Header Redaction** β€” Mask sensitive response headers before writing tapes
14
- - πŸ“¦ **Zero Config** β€” Works out of the box with sensible defaults
15
- - πŸ”’ **Binary Safe** β€” Handles images, compressed responses, and any content type
16
- - 🏷️ **Replay Header** β€” Responses include `X-Api-Tape: Replayed` for easy debugging
17
- - 🧱 **Versioned Tape Schema** β€” Each tape includes `schemaVersion` for compatibility checks
9
+ - **Record Mode** β€” Proxies requests to your target API and saves responses
10
+ - **Replay Mode** β€” Serves cached responses instantly from disk
11
+ - **Hybrid Mode** β€” Replays cached tapes, falls back to upstream on cache miss
12
+ - **Tape Management Commands** β€” List, inspect, clear, and prune tapes from CLI
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
15
+ - **Zero Config** β€” Works out of the box with sensible defaults
16
+ - **Binary Safe** β€” Handles images, compressed responses, and any content type
17
+ - **Replay Header** β€” Responses include `X-Api-Tape: Replayed` for easy debugging
18
+ - **Versioned Tape Schema** β€” Each tape includes `schemaVersion` for compatibility checks
18
19
 
19
- ## πŸ“¦ Installation
20
+ ## Installation
20
21
 
21
22
  ```bash
22
23
  npm install -g api-tape
@@ -28,7 +29,7 @@ Or use it directly with npx:
28
29
  npx api-tape --target "https://api.example.com" --mode record
29
30
  ```
30
31
 
31
- ## πŸš€ Quick Start
32
+ ## Quick Start
32
33
 
33
34
  ### Step 1: Record API Responses
34
35
 
@@ -70,7 +71,7 @@ tape --target "https://jsonplaceholder.typicode.com" --mode hybrid --record-on-m
70
71
  - If tape is missing β†’ upstream request is proxied.
71
72
  - With `--record-on-miss true`, miss responses are automatically saved as new tapes.
72
73
 
73
- ## βš™οΈ CLI Options
74
+ ## CLI Options
74
75
 
75
76
  ### Serve command
76
77
 
@@ -85,6 +86,20 @@ Both legacy mode (`tape --target ...`) and explicit serve command (`tape serve -
85
86
  | `--record-on-miss <boolean>` | In hybrid mode, save upstream response when tape is missing | `true` |
86
87
  | `--redact-header <headers>` | Comma-separated response header names to redact in saved tapes | β€” |
87
88
 
89
+ ### Runtime stats
90
+
91
+ ```bash
92
+ tape serve --target "https://jsonplaceholder.typicode.com" --mode hybrid --stats-interval 10
93
+ ```
94
+
95
+ For machine-readable output:
96
+
97
+ ```bash
98
+ tape serve --target "https://jsonplaceholder.typicode.com" --stats-interval 10 --stats-json
99
+ ```
100
+
101
+ On shutdown, API Tape always prints a final summary (`FINAL_STATS`).
102
+
88
103
  ### Tape management commands
89
104
 
90
105
  ```bash
@@ -94,7 +109,7 @@ tape tape clear --yes --dir ./tapes
94
109
  tape tape prune --older-than 30 --dir ./tapes
95
110
  ```
96
111
 
97
- ## πŸ“ Tape Format
112
+ ## Tape Format
98
113
 
99
114
  Each tape is a JSON file named with an MD5 hash of `METHOD|URL`:
100
115
 
@@ -114,20 +129,20 @@ Each tape is a JSON file named with an MD5 hash of `METHOD|URL`:
114
129
 
115
130
  The body is base64-encoded for binary safety.
116
131
 
117
- ## πŸ§ͺ Development
132
+ ## Development
118
133
 
119
134
  ```bash
120
135
  npm run build
121
136
  npm test
122
137
  ```
123
138
 
124
- ## 🎯 Use Cases
139
+ ## Use Cases
125
140
 
126
141
  - **Offline Development** β€” Work without internet or VPN
127
142
  - **Flaky API Testing** β€” Eliminate network inconsistencies in tests
128
143
  - **Demo Environments** β€” Reproducible API responses for presentations
129
144
  - **Rate Limit Bypass** β€” Develop against recorded responses
130
145
 
131
- ## πŸ“„ License
146
+ ## License
132
147
 
133
148
  MIT Β© [Erdem Arslan](https://github.com/laphilosophia)
package/dist/index.js CHANGED
@@ -55,6 +55,13 @@ 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
@@ -96,6 +103,25 @@ var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders) => ({
96
103
  ),
97
104
  body: bodyBuffer.toString("base64")
98
105
  });
106
+ var buildMetricsSnapshot = (metrics) => ({
107
+ totalRequests: metrics.totalRequests,
108
+ replayHits: metrics.replayHits,
109
+ replayMisses: metrics.replayMisses,
110
+ upstreamRequests: metrics.upstreamRequests,
111
+ upstreamErrors: metrics.upstreamErrors,
112
+ completedResponses: metrics.completedResponses,
113
+ averageLatencyMs: metrics.completedResponses === 0 ? 0 : Number((metrics.totalLatencyMs / metrics.completedResponses).toFixed(2))
114
+ });
115
+ var printMetrics = (metrics, asJson, prefix = "STATS") => {
116
+ const snapshot = buildMetricsSnapshot(metrics);
117
+ if (asJson) {
118
+ console.log(JSON.stringify({ event: prefix, ...snapshot }));
119
+ return;
120
+ }
121
+ console.log(
122
+ `${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}`
123
+ );
124
+ };
99
125
  var runServe = (opts) => {
100
126
  const targetUrl = opts.target;
101
127
  const port = parsePositiveInt(opts.port, "Port");
@@ -103,6 +129,8 @@ var runServe = (opts) => {
103
129
  const tapesDir = import_path.default.resolve(opts.dir);
104
130
  const recordOnMiss = opts.recordOnMiss;
105
131
  const redactedHeaders = parseCsv(opts.redactHeader);
132
+ const statsIntervalSec = parseNonNegativeInt(opts.statsInterval, "stats-interval");
133
+ const statsJson = opts.statsJson;
106
134
  const validModes = ["record", "replay", "hybrid"];
107
135
  if (!validModes.includes(mode)) {
108
136
  throw new Error(`Invalid mode: ${mode}. Expected one of: ${validModes.join(", ")}`);
@@ -114,6 +142,16 @@ var runServe = (opts) => {
114
142
  selfHandleResponse: true
115
143
  });
116
144
  const recordByRequest = /* @__PURE__ */ new WeakMap();
145
+ const requestStartByResponse = /* @__PURE__ */ new WeakMap();
146
+ const metrics = {
147
+ totalRequests: 0,
148
+ replayHits: 0,
149
+ replayMisses: 0,
150
+ upstreamRequests: 0,
151
+ upstreamErrors: 0,
152
+ totalLatencyMs: 0,
153
+ completedResponses: 0
154
+ };
117
155
  const replayTape = (req, res, tapePath) => {
118
156
  if (!import_fs_extra.default.existsSync(tapePath)) {
119
157
  return false;
@@ -127,6 +165,7 @@ var runServe = (opts) => {
127
165
  res.setHeader("X-Api-Tape", "Replayed");
128
166
  res.writeHead(tape.statusCode);
129
167
  res.end(Buffer.from(tape.body, "base64"));
168
+ metrics.replayHits += 1;
130
169
  console.log(`${timestamp()} ${import_chalk2.default.green("\u21BA REPLAY_HIT")} ${req.method} ${req.url}`);
131
170
  return true;
132
171
  } catch (error) {
@@ -142,19 +181,31 @@ var runServe = (opts) => {
142
181
  };
143
182
  const proxyRequest = (req, res, shouldRecord, logPrefix) => {
144
183
  recordByRequest.set(req, shouldRecord);
184
+ metrics.upstreamRequests += 1;
145
185
  console.log(`${timestamp()} ${logPrefix} ${req.method} ${req.url}`);
146
186
  proxy.web(req, res, {}, (error) => {
187
+ metrics.upstreamErrors += 1;
147
188
  console.error(import_chalk2.default.red("Proxy Error:"), error.message);
148
189
  res.statusCode = 502;
149
190
  res.end("Proxy Error");
150
191
  });
151
192
  };
152
193
  const server = import_http.default.createServer((req, res) => {
194
+ metrics.totalRequests += 1;
195
+ requestStartByResponse.set(res, Date.now());
196
+ res.on("finish", () => {
197
+ const startedAt = requestStartByResponse.get(res);
198
+ if (typeof startedAt === "number") {
199
+ metrics.totalLatencyMs += Date.now() - startedAt;
200
+ metrics.completedResponses += 1;
201
+ }
202
+ });
153
203
  const tapeKey = getTapeKey(req);
154
204
  const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
155
205
  if (mode === "replay") {
156
206
  const hit2 = replayTape(req, res, tapePath);
157
207
  if (!hit2) {
208
+ metrics.replayMisses += 1;
158
209
  console.log(`${timestamp()} ${import_chalk2.default.red("\u2718 REPLAY_MISS")} ${req.method} ${req.url}`);
159
210
  res.statusCode = 404;
160
211
  res.end(`Tape not found for: ${req.method} ${req.url}`);
@@ -169,6 +220,7 @@ var runServe = (opts) => {
169
220
  if (hit) {
170
221
  return;
171
222
  }
223
+ metrics.replayMisses += 1;
172
224
  console.log(`${timestamp()} ${import_chalk2.default.yellow("\u21E2 REPLAY_MISS")} ${req.method} ${req.url}`);
173
225
  proxyRequest(
174
226
  req,
@@ -197,6 +249,27 @@ var runServe = (opts) => {
197
249
  res.end(bodyBuffer);
198
250
  });
199
251
  });
252
+ let metricsTimer;
253
+ if (statsIntervalSec > 0) {
254
+ metricsTimer = setInterval(() => {
255
+ printMetrics(metrics, statsJson);
256
+ }, statsIntervalSec * 1e3);
257
+ }
258
+ const shutdown = (signal) => {
259
+ if (metricsTimer) {
260
+ clearInterval(metricsTimer);
261
+ metricsTimer = void 0;
262
+ }
263
+ printMetrics(metrics, statsJson, "FINAL_STATS");
264
+ server.close(() => {
265
+ process.exit(0);
266
+ });
267
+ setTimeout(() => {
268
+ process.exit(0);
269
+ }, 2e3).unref();
270
+ };
271
+ process.once("SIGINT", () => shutdown("SIGINT"));
272
+ process.once("SIGTERM", () => shutdown("SIGTERM"));
200
273
  console.log(import_chalk2.default.bold(`
201
274
  \u{1F4FC} API Tape Running`));
202
275
  console.log(
@@ -211,6 +284,12 @@ var runServe = (opts) => {
211
284
  if (redactedHeaders.length > 0) {
212
285
  console.log(` ${import_chalk2.default.dim("Redact Headers:")} ${redactedHeaders.join(", ")}`);
213
286
  }
287
+ if (statsIntervalSec > 0) {
288
+ console.log(` ${import_chalk2.default.dim("Stats Every:")} ${statsIntervalSec}s`);
289
+ }
290
+ if (statsJson) {
291
+ console.log(` ${import_chalk2.default.dim("Stats Format:")} json`);
292
+ }
214
293
  console.log("");
215
294
  server.listen(port);
216
295
  };
@@ -288,19 +367,19 @@ var addServeOptions = (command) => command.requiredOption("-t, --target <url>",
288
367
  "--redact-header <headers>",
289
368
  "Comma-separated response header names to redact before saving",
290
369
  ""
291
- );
370
+ ).option("--stats-interval <seconds>", "Emit runtime stats every N seconds (0 disables)", "0").option("--stats-json", "Emit stats in JSON format", false);
292
371
  var run = () => {
293
372
  const argv = process.argv;
294
373
  const hasSubCommand = argv.length > 2 && ["serve", "tape"].includes(argv[2]);
295
374
  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) => {
375
+ 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
376
  runServe(options);
298
377
  });
299
378
  legacy.parse(argv);
300
379
  return;
301
380
  }
302
381
  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");
382
+ program.name("api-tape").description("Record and Replay HTTP API responses for offline development.").version("1.3.0");
304
383
  addServeOptions(program.command("serve").description("Run API Tape proxy server")).action(
305
384
  (options) => {
306
385
  runServe(options);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@laphilosophia/api-tape",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "description": "Record and Replay HTTP API responses for offline development",
5
5
  "main": "dist/index.js",
6
6
  "bin": {