@laphilosophia/api-tape 1.1.0 → 1.2.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 +23 -7
- package/dist/index.js +261 -182
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,6 +9,8 @@ API Tape is a zero-config CLI tool that acts as a transparent HTTP proxy. It rec
|
|
|
9
9
|
- 🎬 **Record Mode** — Proxies requests to your target API and saves responses
|
|
10
10
|
- 🔄 **Replay Mode** — Serves cached responses instantly from disk
|
|
11
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
|
|
12
14
|
- 📦 **Zero Config** — Works out of the box with sensible defaults
|
|
13
15
|
- 🔒 **Binary Safe** — Handles images, compressed responses, and any content type
|
|
14
16
|
- 🏷️ **Replay Header** — Responses include `X-Api-Tape: Replayed` for easy debugging
|
|
@@ -70,13 +72,27 @@ tape --target "https://jsonplaceholder.typicode.com" --mode hybrid --record-on-m
|
|
|
70
72
|
|
|
71
73
|
## ⚙️ CLI Options
|
|
72
74
|
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
|
78
|
-
|
|
|
79
|
-
|
|
|
75
|
+
### Serve command
|
|
76
|
+
|
|
77
|
+
Both legacy mode (`tape --target ...`) and explicit serve command (`tape serve --target ...`) are supported.
|
|
78
|
+
|
|
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 | — |
|
|
87
|
+
|
|
88
|
+
### Tape management commands
|
|
89
|
+
|
|
90
|
+
```bash
|
|
91
|
+
tape tape list --dir ./tapes
|
|
92
|
+
tape tape inspect <hash> --dir ./tapes
|
|
93
|
+
tape tape clear --yes --dir ./tapes
|
|
94
|
+
tape tape prune --older-than 30 --dir ./tapes
|
|
95
|
+
```
|
|
80
96
|
|
|
81
97
|
## 📁 Tape Format
|
|
82
98
|
|
package/dist/index.js
CHANGED
|
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
));
|
|
25
25
|
|
|
26
26
|
// src/index.ts
|
|
27
|
-
var
|
|
27
|
+
var import_chalk2 = __toESM(require("chalk"));
|
|
28
28
|
var import_commander = require("commander");
|
|
29
29
|
var import_crypto = __toESM(require("crypto"));
|
|
30
30
|
var import_fs_extra = __toESM(require("fs-extra"));
|
|
@@ -32,66 +32,12 @@ var import_http = __toESM(require("http"));
|
|
|
32
32
|
var import_http_proxy = __toESM(require("http-proxy"));
|
|
33
33
|
var import_path = __toESM(require("path"));
|
|
34
34
|
|
|
35
|
-
//
|
|
36
|
-
var package_default = {
|
|
37
|
-
name: "@laphilosophia/api-tape",
|
|
38
|
-
version: "1.1.0",
|
|
39
|
-
description: "Record and Replay HTTP API responses for offline development",
|
|
40
|
-
main: "dist/index.js",
|
|
41
|
-
bin: {
|
|
42
|
-
tape: "./dist/index.js",
|
|
43
|
-
"api-tape": "./dist/index.js"
|
|
44
|
-
},
|
|
45
|
-
files: [
|
|
46
|
-
"dist"
|
|
47
|
-
],
|
|
48
|
-
publishConfig: {
|
|
49
|
-
access: "public"
|
|
50
|
-
},
|
|
51
|
-
scripts: {
|
|
52
|
-
build: "tsup src/index.ts --format cjs --clean",
|
|
53
|
-
prepublishOnly: "npm run build",
|
|
54
|
-
test: "npm run build && node --test test/**/*.test.cjs"
|
|
55
|
-
},
|
|
56
|
-
keywords: [
|
|
57
|
-
"api",
|
|
58
|
-
"mock",
|
|
59
|
-
"record",
|
|
60
|
-
"replay",
|
|
61
|
-
"proxy",
|
|
62
|
-
"offline",
|
|
63
|
-
"testing",
|
|
64
|
-
"http",
|
|
65
|
-
"vcr"
|
|
66
|
-
],
|
|
67
|
-
author: "Erdem Arslan",
|
|
68
|
-
license: "MIT",
|
|
69
|
-
repository: {
|
|
70
|
-
type: "git",
|
|
71
|
-
url: "git+https://github.com/laphilosophia/api-tape.git"
|
|
72
|
-
},
|
|
73
|
-
homepage: "https://github.com/laphilosophia/api-tape#readme",
|
|
74
|
-
bugs: {
|
|
75
|
-
url: "https://github.com/laphilosophia/api-tape/issues"
|
|
76
|
-
},
|
|
77
|
-
type: "commonjs",
|
|
78
|
-
dependencies: {
|
|
79
|
-
chalk: "^5.6.2",
|
|
80
|
-
commander: "^14.0.2",
|
|
81
|
-
"fs-extra": "^11.3.3",
|
|
82
|
-
"http-proxy": "^1.18.1"
|
|
83
|
-
},
|
|
84
|
-
devDependencies: {
|
|
85
|
-
"@types/fs-extra": "^11.0.4",
|
|
86
|
-
"@types/http-proxy": "^1.17.17",
|
|
87
|
-
"@types/node": "^25.0.8",
|
|
88
|
-
tsup: "^8.5.1",
|
|
89
|
-
typescript: "^5.9.3"
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
|
|
93
|
-
// src/index.ts
|
|
35
|
+
// src/constants.ts
|
|
94
36
|
var CURRENT_SCHEMA_VERSION = 1;
|
|
37
|
+
|
|
38
|
+
// src/utils.ts
|
|
39
|
+
var import_chalk = __toESM(require("chalk"));
|
|
40
|
+
var timestamp = () => import_chalk.default.gray(`[${(/* @__PURE__ */ new Date()).toLocaleTimeString()}]`);
|
|
95
41
|
var parseBooleanOption = (value) => {
|
|
96
42
|
const normalized = value.toLowerCase().trim();
|
|
97
43
|
if (normalized === "true") {
|
|
@@ -102,144 +48,277 @@ var parseBooleanOption = (value) => {
|
|
|
102
48
|
}
|
|
103
49
|
throw new Error('Option must be either "true" or "false".');
|
|
104
50
|
};
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
var
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
var TAPES_DIR = import_path.default.resolve(opts.dir);
|
|
116
|
-
var RECORD_ON_MISS = opts.recordOnMiss;
|
|
117
|
-
var VALID_MODES = ["record", "replay", "hybrid"];
|
|
118
|
-
if (!Number.isInteger(PORT) || PORT <= 0) {
|
|
119
|
-
console.error(import_chalk.default.red("Invalid port. Please provide a positive integer."));
|
|
120
|
-
process.exit(1);
|
|
121
|
-
}
|
|
122
|
-
if (!VALID_MODES.includes(MODE)) {
|
|
123
|
-
console.error(import_chalk.default.red(`Invalid mode: ${MODE}. Expected one of: ${VALID_MODES.join(", ")}`));
|
|
124
|
-
process.exit(1);
|
|
125
|
-
}
|
|
126
|
-
var proxy = import_http_proxy.default.createProxyServer({
|
|
127
|
-
target: TARGET_URL,
|
|
128
|
-
changeOrigin: true,
|
|
129
|
-
selfHandleResponse: true
|
|
130
|
-
});
|
|
131
|
-
import_fs_extra.default.ensureDirSync(TAPES_DIR);
|
|
51
|
+
var parsePositiveInt = (value, label) => {
|
|
52
|
+
const parsed = parseInt(value, 10);
|
|
53
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
54
|
+
throw new Error(`${label} must be a positive integer.`);
|
|
55
|
+
}
|
|
56
|
+
return parsed;
|
|
57
|
+
};
|
|
58
|
+
var parseCsv = (value) => value.split(",").map((item) => item.trim()).filter(Boolean);
|
|
59
|
+
|
|
60
|
+
// src/index.ts
|
|
132
61
|
var getTapeKey = (req) => {
|
|
133
62
|
const key = `${req.method}|${req.url}`;
|
|
134
63
|
return import_crypto.default.createHash("md5").update(key).digest("hex");
|
|
135
64
|
};
|
|
136
|
-
var
|
|
137
|
-
var
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
65
|
+
var readTape = (tapePath) => import_fs_extra.default.readJsonSync(tapePath);
|
|
66
|
+
var redactHeaders = (headers, redactedHeaders) => {
|
|
67
|
+
if (redactedHeaders.length === 0) {
|
|
68
|
+
return headers;
|
|
69
|
+
}
|
|
70
|
+
const redactedSet = new Set(redactedHeaders.map((header) => header.toLowerCase()));
|
|
71
|
+
const nextHeaders = { ...headers };
|
|
72
|
+
Object.keys(nextHeaders).forEach((key) => {
|
|
73
|
+
if (redactedSet.has(key.toLowerCase())) {
|
|
74
|
+
nextHeaders[key] = "[REDACTED]";
|
|
75
|
+
}
|
|
76
|
+
});
|
|
77
|
+
return nextHeaders;
|
|
78
|
+
};
|
|
79
|
+
var ensureSchemaCompatibility = (record, tapePath) => {
|
|
80
|
+
const schemaVersion = record.schemaVersion ?? 0;
|
|
81
|
+
if (![0, CURRENT_SCHEMA_VERSION].includes(schemaVersion)) {
|
|
82
|
+
throw new Error(`Unsupported tape schema version at ${tapePath}: ${schemaVersion}`);
|
|
141
83
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
84
|
+
};
|
|
85
|
+
var createTapeRecord = (req, proxyRes, bodyBuffer, redactedHeaders) => ({
|
|
86
|
+
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
87
|
+
meta: {
|
|
88
|
+
url: req.url,
|
|
89
|
+
method: req.method,
|
|
90
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
91
|
+
},
|
|
92
|
+
statusCode: proxyRes.statusCode || 200,
|
|
93
|
+
headers: redactHeaders(
|
|
94
|
+
proxyRes.headers,
|
|
95
|
+
redactedHeaders
|
|
96
|
+
),
|
|
97
|
+
body: bodyBuffer.toString("base64")
|
|
98
|
+
});
|
|
99
|
+
var runServe = (opts) => {
|
|
100
|
+
const targetUrl = opts.target;
|
|
101
|
+
const port = parsePositiveInt(opts.port, "Port");
|
|
102
|
+
const mode = opts.mode;
|
|
103
|
+
const tapesDir = import_path.default.resolve(opts.dir);
|
|
104
|
+
const recordOnMiss = opts.recordOnMiss;
|
|
105
|
+
const redactedHeaders = parseCsv(opts.redactHeader);
|
|
106
|
+
const validModes = ["record", "replay", "hybrid"];
|
|
107
|
+
if (!validModes.includes(mode)) {
|
|
108
|
+
throw new Error(`Invalid mode: ${mode}. Expected one of: ${validModes.join(", ")}`);
|
|
109
|
+
}
|
|
110
|
+
import_fs_extra.default.ensureDirSync(tapesDir);
|
|
111
|
+
const proxy = import_http_proxy.default.createProxyServer({
|
|
112
|
+
target: targetUrl,
|
|
113
|
+
changeOrigin: true,
|
|
114
|
+
selfHandleResponse: true
|
|
115
|
+
});
|
|
116
|
+
const recordByRequest = /* @__PURE__ */ new WeakMap();
|
|
117
|
+
const replayTape = (req, res, tapePath) => {
|
|
118
|
+
if (!import_fs_extra.default.existsSync(tapePath)) {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
try {
|
|
122
|
+
const tape = readTape(tapePath);
|
|
123
|
+
ensureSchemaCompatibility(tape, tapePath);
|
|
124
|
+
Object.keys(tape.headers).forEach((key) => {
|
|
125
|
+
res.setHeader(key, tape.headers[key]);
|
|
126
|
+
});
|
|
127
|
+
res.setHeader("X-Api-Tape", "Replayed");
|
|
128
|
+
res.writeHead(tape.statusCode);
|
|
129
|
+
res.end(Buffer.from(tape.body, "base64"));
|
|
130
|
+
console.log(`${timestamp()} ${import_chalk2.default.green("\u21BA REPLAY_HIT")} ${req.method} ${req.url}`);
|
|
131
|
+
return true;
|
|
132
|
+
} catch (error) {
|
|
133
|
+
console.error(
|
|
134
|
+
import_chalk2.default.red("Corrupted Tape:"),
|
|
135
|
+
tapePath,
|
|
136
|
+
error instanceof Error ? error.message : "unknown error"
|
|
137
|
+
);
|
|
146
138
|
res.statusCode = 500;
|
|
147
|
-
res.end(
|
|
148
|
-
console.error(import_chalk.default.red("Unsupported Tape Schema:"), tapePath);
|
|
139
|
+
res.end("Corrupted Tape");
|
|
149
140
|
return true;
|
|
150
141
|
}
|
|
151
|
-
|
|
152
|
-
|
|
142
|
+
};
|
|
143
|
+
const proxyRequest = (req, res, shouldRecord, logPrefix) => {
|
|
144
|
+
recordByRequest.set(req, shouldRecord);
|
|
145
|
+
console.log(`${timestamp()} ${logPrefix} ${req.method} ${req.url}`);
|
|
146
|
+
proxy.web(req, res, {}, (error) => {
|
|
147
|
+
console.error(import_chalk2.default.red("Proxy Error:"), error.message);
|
|
148
|
+
res.statusCode = 502;
|
|
149
|
+
res.end("Proxy Error");
|
|
153
150
|
});
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
151
|
+
};
|
|
152
|
+
const server = import_http.default.createServer((req, res) => {
|
|
153
|
+
const tapeKey = getTapeKey(req);
|
|
154
|
+
const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
|
|
155
|
+
if (mode === "replay") {
|
|
156
|
+
const hit2 = replayTape(req, res, tapePath);
|
|
157
|
+
if (!hit2) {
|
|
158
|
+
console.log(`${timestamp()} ${import_chalk2.default.red("\u2718 REPLAY_MISS")} ${req.method} ${req.url}`);
|
|
159
|
+
res.statusCode = 404;
|
|
160
|
+
res.end(`Tape not found for: ${req.method} ${req.url}`);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (mode === "record") {
|
|
165
|
+
proxyRequest(req, res, true, import_chalk2.default.blue("\u25CF RECORD"));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
const hit = replayTape(req, res, tapePath);
|
|
169
|
+
if (hit) {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
console.log(`${timestamp()} ${import_chalk2.default.yellow("\u21E2 REPLAY_MISS")} ${req.method} ${req.url}`);
|
|
173
|
+
proxyRequest(
|
|
174
|
+
req,
|
|
175
|
+
res,
|
|
176
|
+
recordOnMiss,
|
|
177
|
+
recordOnMiss ? import_chalk2.default.magenta("\u21E2 FALLBACK_RECORD") : import_chalk2.default.magenta("\u21E2 FALLBACK_PROXY")
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
proxy.on("proxyRes", (proxyRes, req, res) => {
|
|
181
|
+
const bodyChunks = [];
|
|
182
|
+
proxyRes.on("data", (chunk) => bodyChunks.push(chunk));
|
|
183
|
+
proxyRes.on("end", () => {
|
|
184
|
+
const bodyBuffer = Buffer.concat(bodyChunks);
|
|
185
|
+
const shouldRecord = recordByRequest.get(req) ?? true;
|
|
186
|
+
if (shouldRecord) {
|
|
187
|
+
const tapeData = createTapeRecord(req, proxyRes, bodyBuffer, redactedHeaders);
|
|
188
|
+
const tapeKey = getTapeKey(req);
|
|
189
|
+
const tapePath = import_path.default.join(tapesDir, `${tapeKey}.json`);
|
|
190
|
+
import_fs_extra.default.writeJsonSync(tapePath, tapeData, { spaces: 2 });
|
|
191
|
+
console.log(`${timestamp()} ${import_chalk2.default.cyan("\u{1F4BE} SAVED")} ${req.method} ${req.url}`);
|
|
192
|
+
}
|
|
193
|
+
Object.keys(proxyRes.headers).forEach((key) => {
|
|
194
|
+
res.setHeader(key, proxyRes.headers[key]);
|
|
195
|
+
});
|
|
196
|
+
res.writeHead(proxyRes.statusCode || 200);
|
|
197
|
+
res.end(bodyBuffer);
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
console.log(import_chalk2.default.bold(`
|
|
201
|
+
\u{1F4FC} API Tape Running`));
|
|
202
|
+
console.log(
|
|
203
|
+
` ${import_chalk2.default.dim("Mode:")} ${mode === "record" ? import_chalk2.default.red("\u25CF RECORD") : mode === "hybrid" ? import_chalk2.default.yellow("\u21E2 HYBRID") : import_chalk2.default.green("\u21BA REPLAY")}`
|
|
204
|
+
);
|
|
205
|
+
console.log(` ${import_chalk2.default.dim("Target:")} ${targetUrl}`);
|
|
206
|
+
console.log(` ${import_chalk2.default.dim("Port:")} http://localhost:${port}`);
|
|
207
|
+
console.log(` ${import_chalk2.default.dim("Dir:")} ${tapesDir}`);
|
|
208
|
+
if (mode === "hybrid") {
|
|
209
|
+
console.log(` ${import_chalk2.default.dim("Record Miss:")} ${recordOnMiss}`);
|
|
164
210
|
}
|
|
211
|
+
if (redactedHeaders.length > 0) {
|
|
212
|
+
console.log(` ${import_chalk2.default.dim("Redact Headers:")} ${redactedHeaders.join(", ")}`);
|
|
213
|
+
}
|
|
214
|
+
console.log("");
|
|
215
|
+
server.listen(port);
|
|
165
216
|
};
|
|
166
|
-
var
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
217
|
+
var listTapes = (dir) => {
|
|
218
|
+
const tapesDir = import_path.default.resolve(dir);
|
|
219
|
+
import_fs_extra.default.ensureDirSync(tapesDir);
|
|
220
|
+
const files = import_fs_extra.default.readdirSync(tapesDir).filter((file) => file.endsWith(".json")).sort();
|
|
221
|
+
if (files.length === 0) {
|
|
222
|
+
console.log("No tapes found.");
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
files.forEach((file) => {
|
|
226
|
+
const tapePath = import_path.default.join(tapesDir, file);
|
|
227
|
+
const tape = readTape(tapePath);
|
|
228
|
+
ensureSchemaCompatibility(tape, tapePath);
|
|
229
|
+
const method = tape.meta.method || "UNKNOWN";
|
|
230
|
+
const route = tape.meta.url || "(unknown-url)";
|
|
231
|
+
const time = tape.meta.timestamp || "(unknown-time)";
|
|
232
|
+
console.log(`${import_path.default.basename(file, ".json")} ${method} ${route} ${time}`);
|
|
173
233
|
});
|
|
174
234
|
};
|
|
175
|
-
var
|
|
176
|
-
const
|
|
177
|
-
const tapePath = import_path.default.join(
|
|
178
|
-
if (
|
|
179
|
-
|
|
180
|
-
if (!hit2) {
|
|
181
|
-
console.log(`${timestamp()} ${import_chalk.default.red("\u2718 REPLAY_MISS")} ${req.method} ${req.url}`);
|
|
182
|
-
res.statusCode = 404;
|
|
183
|
-
res.end(`Tape not found for: ${req.method} ${req.url}`);
|
|
184
|
-
}
|
|
185
|
-
return;
|
|
235
|
+
var inspectTape = (dir, tapeId) => {
|
|
236
|
+
const tapesDir = import_path.default.resolve(dir);
|
|
237
|
+
const tapePath = import_path.default.join(tapesDir, `${tapeId}.json`);
|
|
238
|
+
if (!import_fs_extra.default.existsSync(tapePath)) {
|
|
239
|
+
throw new Error(`Tape not found: ${tapeId}`);
|
|
186
240
|
}
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
241
|
+
const tape = readTape(tapePath);
|
|
242
|
+
ensureSchemaCompatibility(tape, tapePath);
|
|
243
|
+
const preview = {
|
|
244
|
+
id: tapeId,
|
|
245
|
+
schemaVersion: tape.schemaVersion ?? 0,
|
|
246
|
+
meta: tape.meta,
|
|
247
|
+
statusCode: tape.statusCode,
|
|
248
|
+
headers: tape.headers,
|
|
249
|
+
bodyBytes: Buffer.from(tape.body, "base64").byteLength
|
|
250
|
+
};
|
|
251
|
+
console.log(JSON.stringify(preview, null, 2));
|
|
252
|
+
};
|
|
253
|
+
var clearTapes = (dir, confirmed) => {
|
|
254
|
+
if (!confirmed) {
|
|
255
|
+
throw new Error("Refusing to clear tapes without --yes flag.");
|
|
190
256
|
}
|
|
191
|
-
const
|
|
192
|
-
|
|
257
|
+
const tapesDir = import_path.default.resolve(dir);
|
|
258
|
+
import_fs_extra.default.ensureDirSync(tapesDir);
|
|
259
|
+
const files = import_fs_extra.default.readdirSync(tapesDir).filter((file) => file.endsWith(".json"));
|
|
260
|
+
files.forEach((file) => {
|
|
261
|
+
import_fs_extra.default.removeSync(import_path.default.join(tapesDir, file));
|
|
262
|
+
});
|
|
263
|
+
console.log(`Cleared ${files.length} tape file(s).`);
|
|
264
|
+
};
|
|
265
|
+
var pruneTapes = (dir, olderThanDays) => {
|
|
266
|
+
const days = parsePositiveInt(olderThanDays, "older-than");
|
|
267
|
+
const cutoff = Date.now() - days * 24 * 60 * 60 * 1e3;
|
|
268
|
+
const tapesDir = import_path.default.resolve(dir);
|
|
269
|
+
import_fs_extra.default.ensureDirSync(tapesDir);
|
|
270
|
+
const files = import_fs_extra.default.readdirSync(tapesDir).filter((file) => file.endsWith(".json"));
|
|
271
|
+
let removed = 0;
|
|
272
|
+
files.forEach((file) => {
|
|
273
|
+
const target = import_path.default.join(tapesDir, file);
|
|
274
|
+
const stat = import_fs_extra.default.statSync(target);
|
|
275
|
+
if (stat.mtimeMs < cutoff) {
|
|
276
|
+
import_fs_extra.default.removeSync(target);
|
|
277
|
+
removed += 1;
|
|
278
|
+
}
|
|
279
|
+
});
|
|
280
|
+
console.log(`Pruned ${removed} tape file(s) older than ${days} day(s).`);
|
|
281
|
+
};
|
|
282
|
+
var addServeOptions = (command) => command.requiredOption("-t, --target <url>", "Target API URL (e.g., https://api.github.com)").option("-p, --port <number>", "Local server port", "8080").option("-m, --mode <mode>", 'Operation mode: "record", "replay", or "hybrid"', "replay").option("-d, --dir <path>", "Directory to save tapes", "./tapes").option(
|
|
283
|
+
"--record-on-miss <boolean>",
|
|
284
|
+
"In hybrid mode, save upstream response when tape is missing",
|
|
285
|
+
parseBooleanOption,
|
|
286
|
+
true
|
|
287
|
+
).option(
|
|
288
|
+
"--redact-header <headers>",
|
|
289
|
+
"Comma-separated response header names to redact before saving",
|
|
290
|
+
""
|
|
291
|
+
);
|
|
292
|
+
var run = () => {
|
|
293
|
+
const argv = process.argv;
|
|
294
|
+
const hasSubCommand = argv.length > 2 && ["serve", "tape"].includes(argv[2]);
|
|
295
|
+
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) => {
|
|
297
|
+
runServe(options);
|
|
298
|
+
});
|
|
299
|
+
legacy.parse(argv);
|
|
193
300
|
return;
|
|
194
301
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
RECORD_ON_MISS ? import_chalk.default.magenta("\u21E2 FALLBACK_RECORD") : import_chalk.default.magenta("\u21E2 FALLBACK_PROXY")
|
|
201
|
-
);
|
|
202
|
-
});
|
|
203
|
-
proxy.on("proxyRes", (proxyRes, req, res) => {
|
|
204
|
-
const bodyChunks = [];
|
|
205
|
-
proxyRes.on("data", (chunk) => bodyChunks.push(chunk));
|
|
206
|
-
proxyRes.on("end", () => {
|
|
207
|
-
const bodyBuffer = Buffer.concat(bodyChunks);
|
|
208
|
-
const tapeData = {
|
|
209
|
-
schemaVersion: CURRENT_SCHEMA_VERSION,
|
|
210
|
-
meta: {
|
|
211
|
-
url: req.url,
|
|
212
|
-
method: req.method,
|
|
213
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
214
|
-
},
|
|
215
|
-
statusCode: proxyRes.statusCode,
|
|
216
|
-
headers: proxyRes.headers,
|
|
217
|
-
body: bodyBuffer.toString("base64")
|
|
218
|
-
};
|
|
219
|
-
const shouldRecord = recordByRequest.get(req) ?? true;
|
|
220
|
-
if (shouldRecord) {
|
|
221
|
-
const tapeKey = getTapeKey(req);
|
|
222
|
-
const tapePath = import_path.default.join(TAPES_DIR, `${tapeKey}.json`);
|
|
223
|
-
import_fs_extra.default.writeJsonSync(tapePath, tapeData, { spaces: 2 });
|
|
224
|
-
console.log(`${timestamp()} ${import_chalk.default.cyan("\u{1F4BE} SAVED")} ${req.method} ${req.url}`);
|
|
302
|
+
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");
|
|
304
|
+
addServeOptions(program.command("serve").description("Run API Tape proxy server")).action(
|
|
305
|
+
(options) => {
|
|
306
|
+
runServe(options);
|
|
225
307
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
res.end(bodyBuffer);
|
|
308
|
+
);
|
|
309
|
+
const tape = program.command("tape").description("Manage recorded tape files");
|
|
310
|
+
tape.command("list").description("List all recorded tapes").option("-d, --dir <path>", "Directory to save tapes", "./tapes").action((options) => {
|
|
311
|
+
listTapes(options.dir);
|
|
231
312
|
});
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
);
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
console.log("");
|
|
245
|
-
server.listen(PORT);
|
|
313
|
+
tape.command("inspect <id>").description("Inspect a recorded tape by hash id").option("-d, --dir <path>", "Directory to save tapes", "./tapes").action((id, options) => {
|
|
314
|
+
inspectTape(options.dir, id);
|
|
315
|
+
});
|
|
316
|
+
tape.command("clear").description("Delete all tape files from directory").option("-d, --dir <path>", "Directory to save tapes", "./tapes").option("--yes", "Confirm destructive deletion", false).action((options) => {
|
|
317
|
+
clearTapes(options.dir, options.yes);
|
|
318
|
+
});
|
|
319
|
+
tape.command("prune").description("Delete tape files older than N days").requiredOption("--older-than <days>", "Remove files older than N days").option("-d, --dir <path>", "Directory to save tapes", "./tapes").action((options) => {
|
|
320
|
+
pruneTapes(options.dir, options.olderThan);
|
|
321
|
+
});
|
|
322
|
+
program.parse(argv);
|
|
323
|
+
};
|
|
324
|
+
run();
|