@laphilosophia/api-tape 1.2.1 → 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 +15 -0
  2. package/dist/index.js +82 -3
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -11,6 +11,7 @@ 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
@@ -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
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.1",
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": {