@schmock/cli 2.3.0 → 2.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/LICENSE +21 -0
- package/README.md +36 -0
- package/dist/bin.js +455 -91
- package/dist/cli.d.ts +50 -13
- package/dist/cli.d.ts.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +456 -91
- package/package.json +21 -4
- package/dist/cli.js +0 -351
package/dist/bin.js
CHANGED
|
@@ -1,26 +1,63 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
// src/cli.ts
|
|
4
|
-
import {
|
|
4
|
+
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
+
import { readFileSync, realpathSync, statSync, watch } from "node:fs";
|
|
5
6
|
import { createServer } from "node:http";
|
|
7
|
+
import {
|
|
8
|
+
basename,
|
|
9
|
+
dirname,
|
|
10
|
+
isAbsolute,
|
|
11
|
+
relative,
|
|
12
|
+
resolve as resolvePath,
|
|
13
|
+
sep
|
|
14
|
+
} from "node:path";
|
|
6
15
|
import { parseArgs } from "node:util";
|
|
7
16
|
import {
|
|
8
17
|
collectBody,
|
|
9
18
|
HTTP_METHODS,
|
|
19
|
+
HttpIngressError,
|
|
10
20
|
isHttpMethod,
|
|
21
|
+
normalizeResponse,
|
|
11
22
|
parseNodeHeaders,
|
|
12
23
|
parseNodeQuery,
|
|
24
|
+
ResourceLimitError,
|
|
13
25
|
schmock,
|
|
26
|
+
writeRejectedSchmockResponse,
|
|
14
27
|
writeSchmockResponse
|
|
15
28
|
} from "@schmock/core";
|
|
16
|
-
import { openapi } from "@schmock/openapi";
|
|
29
|
+
import { MAX_SEED_MANIFEST_BYTES, openapi } from "@schmock/openapi";
|
|
17
30
|
var MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024;
|
|
31
|
+
var SHUTDOWN_GRACE_MS = 5000;
|
|
18
32
|
var ALLOWED_METHODS = HTTP_METHODS.join(", ");
|
|
19
|
-
var
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
33
|
+
var ADMIN_PATH_PREFIX = "/schmock-admin/";
|
|
34
|
+
var DEFAULT_ADMIN_HISTORY_LIMIT = 500;
|
|
35
|
+
var REDACTED = "[redacted]";
|
|
36
|
+
var SENSITIVE_HEADERS = new Set([
|
|
37
|
+
"authorization",
|
|
38
|
+
"proxy-authorization",
|
|
39
|
+
"cookie",
|
|
40
|
+
"set-cookie",
|
|
41
|
+
"x-api-key",
|
|
42
|
+
"x-auth-token",
|
|
43
|
+
"x-schmock-admin-token"
|
|
44
|
+
]);
|
|
45
|
+
var REQUEST_ADMISSION = Symbol.for("@schmock/core.request-admission");
|
|
46
|
+
function isRequestAdmission(value) {
|
|
47
|
+
return typeof value === "object" && value !== null && "handle" in value && typeof value.handle === "function" && "release" in value && typeof value.release === "function";
|
|
48
|
+
}
|
|
49
|
+
function acquireRequestAdmission(mock) {
|
|
50
|
+
const admit = Reflect.get(mock, REQUEST_ADMISSION);
|
|
51
|
+
if (typeof admit !== "function")
|
|
52
|
+
return;
|
|
53
|
+
const admission = Reflect.apply(admit, mock, []);
|
|
54
|
+
if (!isRequestAdmission(admission)) {
|
|
55
|
+
throw new Error("Schmock returned an invalid request admission");
|
|
56
|
+
}
|
|
57
|
+
return admission;
|
|
58
|
+
}
|
|
59
|
+
var DEFAULT_ALLOWED_REQUEST_HEADERS = "Content-Type, Authorization";
|
|
60
|
+
var REQUEST_HEADER_LIST = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+(?:[ \t]*,[ \t]*[!#$%&'*+\-.^_`|~0-9A-Za-z]+)*$/;
|
|
24
61
|
|
|
25
62
|
class CliHttpError extends Error {
|
|
26
63
|
status;
|
|
@@ -34,25 +71,139 @@ class CliHttpError extends Error {
|
|
|
34
71
|
this.name = "CliHttpError";
|
|
35
72
|
}
|
|
36
73
|
}
|
|
37
|
-
function
|
|
38
|
-
return
|
|
74
|
+
function isCountSource(value) {
|
|
75
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "count" in value && typeof value.count === "number";
|
|
76
|
+
}
|
|
77
|
+
function resolveSeedEntryPath(entry, key, baseDir) {
|
|
78
|
+
let real;
|
|
79
|
+
try {
|
|
80
|
+
real = realpathSync(resolvePath(baseDir, entry));
|
|
81
|
+
} catch {
|
|
82
|
+
throw new Error(`Seed entry "${key}" points to a missing file: ${entry}`);
|
|
83
|
+
}
|
|
84
|
+
const rel = relative(baseDir, real);
|
|
85
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
86
|
+
throw new Error(`Seed entry "${key}" must stay inside the seed manifest directory: ${entry}`);
|
|
87
|
+
}
|
|
88
|
+
return real;
|
|
39
89
|
}
|
|
40
90
|
function loadSeedFile(seedPath) {
|
|
41
|
-
const
|
|
42
|
-
const
|
|
91
|
+
const manifestPath = resolvePath(seedPath);
|
|
92
|
+
const { size } = statSync(manifestPath);
|
|
93
|
+
if (size > MAX_SEED_MANIFEST_BYTES) {
|
|
94
|
+
throw new ResourceLimitError(`seed manifest "${seedPath}"`, MAX_SEED_MANIFEST_BYTES, size);
|
|
95
|
+
}
|
|
96
|
+
const baseDir = realpathSync(dirname(manifestPath));
|
|
97
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
101
|
+
} catch {
|
|
102
|
+
throw new Error(`Seed file "${seedPath}" contains invalid JSON`);
|
|
103
|
+
}
|
|
43
104
|
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
44
|
-
throw new Error(`Seed file must contain a JSON object, got: ${typeof parsed}`);
|
|
105
|
+
throw new Error(`Seed file must contain a JSON object, got: ${Array.isArray(parsed) ? "array" : typeof parsed}`);
|
|
45
106
|
}
|
|
46
107
|
const result = {};
|
|
47
108
|
for (const [key, value] of Object.entries(parsed)) {
|
|
48
|
-
if (
|
|
109
|
+
if (Array.isArray(value)) {
|
|
110
|
+
result[key] = value;
|
|
111
|
+
} else if (typeof value === "string") {
|
|
112
|
+
result[key] = resolveSeedEntryPath(value, key, baseDir);
|
|
113
|
+
} else if (isCountSource(value)) {
|
|
49
114
|
result[key] = value;
|
|
115
|
+
} else {
|
|
116
|
+
throw new Error(`Seed entry "${key}" must be an array, a file path, or { "count": <number> }`);
|
|
50
117
|
}
|
|
51
118
|
}
|
|
52
119
|
return result;
|
|
53
120
|
}
|
|
121
|
+
function isAdminPath(admin, path) {
|
|
122
|
+
return admin && path.startsWith(ADMIN_PATH_PREFIX);
|
|
123
|
+
}
|
|
124
|
+
function singleHeader(value) {
|
|
125
|
+
return typeof value === "string" ? value : undefined;
|
|
126
|
+
}
|
|
127
|
+
function requestedAllowHeaders(req) {
|
|
128
|
+
const requested = singleHeader(req.headers["access-control-request-headers"]);
|
|
129
|
+
const trimmed = requested?.trim();
|
|
130
|
+
if (trimmed === undefined || trimmed === "") {
|
|
131
|
+
return DEFAULT_ALLOWED_REQUEST_HEADERS;
|
|
132
|
+
}
|
|
133
|
+
return REQUEST_HEADER_LIST.test(trimmed) ? trimmed : DEFAULT_ALLOWED_REQUEST_HEADERS;
|
|
134
|
+
}
|
|
135
|
+
function corsHeadersFor(req) {
|
|
136
|
+
return {
|
|
137
|
+
"access-control-allow-origin": "*",
|
|
138
|
+
"access-control-allow-methods": ALLOWED_METHODS,
|
|
139
|
+
"access-control-allow-headers": requestedAllowHeaders(req)
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
function isCorsPreflight(req, method) {
|
|
143
|
+
return method === "OPTIONS" && singleHeader(req.headers.origin) !== undefined && singleHeader(req.headers["access-control-request-method"]) !== undefined;
|
|
144
|
+
}
|
|
145
|
+
function presentedAdminToken(req) {
|
|
146
|
+
const authorization = singleHeader(req.headers.authorization);
|
|
147
|
+
const bearer = authorization ? /^bearer\s+(\S+)$/i.exec(authorization.trim()) : null;
|
|
148
|
+
if (bearer)
|
|
149
|
+
return bearer[1];
|
|
150
|
+
return singleHeader(req.headers["x-schmock-admin-token"]);
|
|
151
|
+
}
|
|
152
|
+
function tokensMatch(expected, presented) {
|
|
153
|
+
const expectedDigest = createHash("sha256").update(expected).digest();
|
|
154
|
+
const presentedDigest = createHash("sha256").update(presented).digest();
|
|
155
|
+
return timingSafeEqual(expectedDigest, presentedDigest);
|
|
156
|
+
}
|
|
157
|
+
function adminResponseHeaders(extra = {}) {
|
|
158
|
+
return {
|
|
159
|
+
"content-type": "application/json",
|
|
160
|
+
"cache-control": "no-store",
|
|
161
|
+
vary: "origin",
|
|
162
|
+
...extra
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
function denyAdminRequest(req, res, adminToken) {
|
|
166
|
+
if (req.headers.origin !== undefined) {
|
|
167
|
+
res.writeHead(403, adminResponseHeaders());
|
|
168
|
+
res.end(JSON.stringify({
|
|
169
|
+
error: "Admin API refuses browser-originated requests",
|
|
170
|
+
code: "FORBIDDEN"
|
|
171
|
+
}));
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const presented = presentedAdminToken(req);
|
|
175
|
+
if (adminToken === undefined || adminToken === "" || presented === undefined || !tokensMatch(adminToken, presented)) {
|
|
176
|
+
res.writeHead(401, adminResponseHeaders({
|
|
177
|
+
"www-authenticate": 'Bearer realm="schmock-admin"'
|
|
178
|
+
}));
|
|
179
|
+
res.end(JSON.stringify({
|
|
180
|
+
error: "Admin API requires a valid bearer token",
|
|
181
|
+
code: "UNAUTHORIZED"
|
|
182
|
+
}));
|
|
183
|
+
return true;
|
|
184
|
+
}
|
|
185
|
+
return false;
|
|
186
|
+
}
|
|
187
|
+
function isRecord(value) {
|
|
188
|
+
return typeof value === "object" && value !== null;
|
|
189
|
+
}
|
|
190
|
+
function redactHeaders(headers) {
|
|
191
|
+
if (!isRecord(headers))
|
|
192
|
+
return {};
|
|
193
|
+
const redacted = {};
|
|
194
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
195
|
+
redacted[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? REDACTED : value;
|
|
196
|
+
}
|
|
197
|
+
return redacted;
|
|
198
|
+
}
|
|
199
|
+
function redactHistory(records) {
|
|
200
|
+
return records.map((record) => ({
|
|
201
|
+
...record,
|
|
202
|
+
headers: redactHeaders(record.headers)
|
|
203
|
+
}));
|
|
204
|
+
}
|
|
54
205
|
function handleAdminRequest(method, path, mock, res, headers) {
|
|
55
|
-
const route = path.replace(
|
|
206
|
+
const route = path.replace(ADMIN_PATH_PREFIX, "");
|
|
56
207
|
if (method === "GET" && route === "routes") {
|
|
57
208
|
res.writeHead(200, headers);
|
|
58
209
|
res.end(JSON.stringify(mock.getRoutes()));
|
|
@@ -72,7 +223,7 @@ function handleAdminRequest(method, path, mock, res, headers) {
|
|
|
72
223
|
}
|
|
73
224
|
if (method === "GET" && route === "history") {
|
|
74
225
|
res.writeHead(200, headers);
|
|
75
|
-
res.end(JSON.stringify(mock.history()));
|
|
226
|
+
res.end(JSON.stringify(redactHistory(mock.history())));
|
|
76
227
|
return;
|
|
77
228
|
}
|
|
78
229
|
res.writeHead(404, headers);
|
|
@@ -96,76 +247,103 @@ function parseRequestMethod(req) {
|
|
|
96
247
|
}
|
|
97
248
|
return method;
|
|
98
249
|
}
|
|
99
|
-
function assertBodySize(headers) {
|
|
100
|
-
const contentLength = headers["content-length"];
|
|
101
|
-
if (contentLength === undefined)
|
|
102
|
-
return;
|
|
103
|
-
const declaredSize = Number(contentLength);
|
|
104
|
-
if (Number.isFinite(declaredSize) && declaredSize > MAX_REQUEST_BODY_SIZE) {
|
|
105
|
-
throw new CliHttpError(413, "PAYLOAD_TOO_LARGE", "Request body too large");
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
250
|
function toCliHttpError(error) {
|
|
109
251
|
if (error instanceof CliHttpError)
|
|
110
252
|
return error;
|
|
111
|
-
if (error
|
|
112
|
-
return new CliHttpError(
|
|
253
|
+
if (error instanceof HttpIngressError) {
|
|
254
|
+
return new CliHttpError(error.status, error.code, error.message);
|
|
113
255
|
}
|
|
114
256
|
return new CliHttpError(500, "SERVER_ERROR", error instanceof Error ? error.message : "Internal Server Error");
|
|
115
257
|
}
|
|
116
258
|
async function handleCliRequest(req, res, mock, options) {
|
|
259
|
+
const abortController = new AbortController;
|
|
260
|
+
const abortRequest = () => abortController.abort();
|
|
261
|
+
req.once("aborted", abortRequest);
|
|
262
|
+
res.once("close", abortRequest);
|
|
263
|
+
let admission;
|
|
264
|
+
let requestMethod = req.method?.toUpperCase() === "HEAD" ? "HEAD" : "GET";
|
|
265
|
+
let adminRequest = false;
|
|
117
266
|
try {
|
|
118
267
|
const url = parseRequestUrl(req);
|
|
119
|
-
const method = parseRequestMethod(req);
|
|
120
268
|
const path = url.pathname;
|
|
121
|
-
|
|
122
|
-
|
|
269
|
+
adminRequest = isAdminPath(options.admin, path);
|
|
270
|
+
const method = parseRequestMethod(req);
|
|
271
|
+
requestMethod = method;
|
|
272
|
+
if (options.cors && !adminRequest && isCorsPreflight(req, method)) {
|
|
273
|
+
res.writeHead(204, corsHeadersFor(req));
|
|
123
274
|
res.end();
|
|
124
275
|
return;
|
|
125
276
|
}
|
|
126
|
-
if (
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
};
|
|
131
|
-
handleAdminRequest(method, path, mock, res, adminHeaders);
|
|
277
|
+
if (adminRequest) {
|
|
278
|
+
if (denyAdminRequest(req, res, options.adminToken))
|
|
279
|
+
return;
|
|
280
|
+
handleAdminRequest(method, path, mock, res, adminResponseHeaders());
|
|
132
281
|
return;
|
|
133
282
|
}
|
|
283
|
+
admission = acquireRequestAdmission(mock);
|
|
284
|
+
const handleRequest = admission?.handle ?? ((admittedMethod, requestPath, requestOptions) => mock.handle(admittedMethod, requestPath, requestOptions));
|
|
134
285
|
const headers = parseNodeHeaders(req);
|
|
135
|
-
assertBodySize(headers);
|
|
136
286
|
const query = parseNodeQuery(url);
|
|
137
287
|
const body = await collectBody(req, headers, MAX_REQUEST_BODY_SIZE);
|
|
138
|
-
const schmockResponse = await
|
|
288
|
+
const schmockResponse = await handleRequest(method, path, {
|
|
139
289
|
headers,
|
|
140
290
|
body,
|
|
141
|
-
query
|
|
291
|
+
query,
|
|
292
|
+
signal: abortController.signal
|
|
142
293
|
});
|
|
143
|
-
const extraHeaders = options.cors ?
|
|
294
|
+
const extraHeaders = options.cors ? corsHeadersFor(req) : undefined;
|
|
144
295
|
writeSchmockResponse(res, schmockResponse, extraHeaders);
|
|
145
296
|
} catch (error) {
|
|
146
297
|
const cliError = toCliHttpError(error);
|
|
147
298
|
try {
|
|
148
|
-
if (
|
|
149
|
-
res.
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
299
|
+
if (cliError.status === 413)
|
|
300
|
+
res.shouldKeepAlive = false;
|
|
301
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
302
|
+
const response = normalizeResponse({
|
|
303
|
+
status: cliError.status,
|
|
304
|
+
body: { error: cliError.message, code: cliError.code },
|
|
305
|
+
headers: {
|
|
306
|
+
"content-type": "application/json",
|
|
307
|
+
...cliError.headers
|
|
308
|
+
}
|
|
309
|
+
}, requestMethod);
|
|
310
|
+
const extraHeaders = {
|
|
311
|
+
...options.cors && !adminRequest ? corsHeadersFor(req) : {},
|
|
312
|
+
...cliError.status === 413 ? { connection: "close" } : {}
|
|
313
|
+
};
|
|
314
|
+
if (cliError.status === 413) {
|
|
315
|
+
writeRejectedSchmockResponse(req, res, response, extraHeaders);
|
|
316
|
+
} else {
|
|
317
|
+
writeSchmockResponse(res, response, extraHeaders);
|
|
318
|
+
}
|
|
319
|
+
} else if (!res.writableEnded) {
|
|
320
|
+
res.end();
|
|
157
321
|
}
|
|
158
322
|
} catch {
|
|
159
323
|
res.destroy();
|
|
160
324
|
}
|
|
325
|
+
} finally {
|
|
326
|
+
req.off("aborted", abortRequest);
|
|
327
|
+
res.off("close", abortRequest);
|
|
328
|
+
admission?.release();
|
|
161
329
|
}
|
|
162
330
|
}
|
|
163
331
|
async function createCliMock(options) {
|
|
164
|
-
const mock = schmock({
|
|
332
|
+
const mock = schmock({
|
|
333
|
+
debug: options.debug,
|
|
334
|
+
state: {},
|
|
335
|
+
maxHistorySize: options.admin ? options.adminHistoryLimit ?? DEFAULT_ADMIN_HISTORY_LIMIT : 0
|
|
336
|
+
});
|
|
165
337
|
const openapiOptions = {
|
|
166
338
|
spec: options.spec,
|
|
167
339
|
fakerSeed: options.fakerSeed,
|
|
168
|
-
validateRequests: options.errors
|
|
340
|
+
validateRequests: options.errors,
|
|
341
|
+
strict: options.strict,
|
|
342
|
+
refs: {
|
|
343
|
+
external: options.refsExternal ?? false,
|
|
344
|
+
allowHttp: options.refsAllowHttp !== undefined,
|
|
345
|
+
allowedHosts: options.refsAllowHttp
|
|
346
|
+
}
|
|
169
347
|
};
|
|
170
348
|
if (options.seed) {
|
|
171
349
|
openapiOptions.seed = loadSeedFile(options.seed);
|
|
@@ -174,33 +352,119 @@ async function createCliMock(options) {
|
|
|
174
352
|
mock.pipe(plugin);
|
|
175
353
|
return mock;
|
|
176
354
|
}
|
|
177
|
-
function
|
|
355
|
+
function boundedDelay(ms) {
|
|
356
|
+
return new Promise((resolve) => {
|
|
357
|
+
setTimeout(resolve, ms).unref();
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
function closeHttpServer(httpServer, graceMs) {
|
|
361
|
+
return new Promise((resolve) => {
|
|
362
|
+
let graceTimer;
|
|
363
|
+
const settle = () => {
|
|
364
|
+
if (graceTimer !== undefined) {
|
|
365
|
+
clearTimeout(graceTimer);
|
|
366
|
+
graceTimer = undefined;
|
|
367
|
+
}
|
|
368
|
+
resolve();
|
|
369
|
+
};
|
|
370
|
+
httpServer.close(() => settle());
|
|
371
|
+
try {
|
|
372
|
+
httpServer.closeIdleConnections();
|
|
373
|
+
} catch {}
|
|
374
|
+
graceTimer = setTimeout(() => {
|
|
375
|
+
try {
|
|
376
|
+
httpServer.closeAllConnections();
|
|
377
|
+
} catch {}
|
|
378
|
+
}, graceMs);
|
|
379
|
+
graceTimer.unref();
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
function startCliServer(options, holder) {
|
|
383
|
+
if (options.hostname !== undefined && options.hostname.trim() === "") {
|
|
384
|
+
throw new Error("Invalid hostname. The hostname must be a non-empty host, address or interface.");
|
|
385
|
+
}
|
|
178
386
|
const hostname = options.hostname ?? "127.0.0.1";
|
|
179
387
|
const port = options.port ?? 3000;
|
|
180
388
|
const cors = options.cors ?? false;
|
|
389
|
+
const graceMs = options.shutdownGraceMs ?? SHUTDOWN_GRACE_MS;
|
|
181
390
|
const admin = options.admin ?? false;
|
|
391
|
+
const adminToken = admin ? options.adminToken : undefined;
|
|
182
392
|
const httpServer = createServer((req, res) => {
|
|
183
|
-
handleCliRequest(req, res, mock, { admin, cors });
|
|
393
|
+
handleCliRequest(req, res, holder.mock, { admin, cors, adminToken });
|
|
184
394
|
});
|
|
395
|
+
const cleanups = [];
|
|
396
|
+
let closing;
|
|
397
|
+
const close = () => {
|
|
398
|
+
closing ??= (async () => {
|
|
399
|
+
const settled = closeHttpServer(httpServer, graceMs);
|
|
400
|
+
const drain = (async () => {
|
|
401
|
+
for (const cleanup of cleanups.splice(0))
|
|
402
|
+
await cleanup();
|
|
403
|
+
})();
|
|
404
|
+
drain.catch(() => {});
|
|
405
|
+
try {
|
|
406
|
+
await Promise.race([drain, boundedDelay(graceMs)]);
|
|
407
|
+
} finally {
|
|
408
|
+
await settled;
|
|
409
|
+
}
|
|
410
|
+
})();
|
|
411
|
+
return closing;
|
|
412
|
+
};
|
|
185
413
|
return new Promise((resolve, reject) => {
|
|
186
|
-
|
|
414
|
+
const onListenError = (error) => reject(error);
|
|
415
|
+
const reportServerError = (error) => {
|
|
416
|
+
process.stderr.write(`Server error: ${error instanceof Error ? error.message : String(error)}
|
|
417
|
+
`);
|
|
418
|
+
};
|
|
419
|
+
httpServer.once("error", onListenError);
|
|
187
420
|
httpServer.listen(port, hostname, () => {
|
|
421
|
+
httpServer.on("error", reportServerError);
|
|
422
|
+
httpServer.off("error", onListenError);
|
|
188
423
|
const addr = httpServer.address();
|
|
189
424
|
const actualPort = addr !== null && typeof addr === "object" ? addr.port : port;
|
|
190
425
|
resolve({
|
|
191
426
|
server: httpServer,
|
|
192
427
|
port: actualPort,
|
|
193
428
|
hostname,
|
|
194
|
-
|
|
195
|
-
|
|
429
|
+
adminToken,
|
|
430
|
+
close,
|
|
431
|
+
onClose(cleanup) {
|
|
432
|
+
cleanups.push(cleanup);
|
|
196
433
|
}
|
|
197
434
|
});
|
|
198
435
|
});
|
|
199
436
|
});
|
|
200
437
|
}
|
|
438
|
+
function resolveAdminToken(options) {
|
|
439
|
+
if (!options.admin)
|
|
440
|
+
return;
|
|
441
|
+
return options.adminToken ?? randomUUID();
|
|
442
|
+
}
|
|
201
443
|
async function createCliServer(options) {
|
|
202
|
-
const
|
|
203
|
-
|
|
444
|
+
const resolved = {
|
|
445
|
+
...options,
|
|
446
|
+
adminToken: resolveAdminToken(options)
|
|
447
|
+
};
|
|
448
|
+
const holder = { mock: await createCliMock(resolved) };
|
|
449
|
+
const server = await startCliServer(resolved, holder);
|
|
450
|
+
if (resolved.watch) {
|
|
451
|
+
let watcher;
|
|
452
|
+
try {
|
|
453
|
+
watcher = startWatch(resolved.spec, resolved, holder, server);
|
|
454
|
+
} catch (error) {
|
|
455
|
+
await server.close();
|
|
456
|
+
throw error;
|
|
457
|
+
}
|
|
458
|
+
server.onClose(() => watcher.close());
|
|
459
|
+
}
|
|
460
|
+
return server;
|
|
461
|
+
}
|
|
462
|
+
function isLoopbackHost(hostname) {
|
|
463
|
+
const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
464
|
+
if (host === "localhost" || host === "::1" || host === "0:0:0:0:0:0:0:1") {
|
|
465
|
+
return true;
|
|
466
|
+
}
|
|
467
|
+
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
204
468
|
}
|
|
205
469
|
function validatePort(value) {
|
|
206
470
|
const port = Number(value);
|
|
@@ -209,6 +473,36 @@ function validatePort(value) {
|
|
|
209
473
|
}
|
|
210
474
|
return port;
|
|
211
475
|
}
|
|
476
|
+
function validateHistoryLimit(value) {
|
|
477
|
+
const limit = value.trim() === "" ? Number.NaN : Number(value);
|
|
478
|
+
if (!Number.isInteger(limit) || limit < 0) {
|
|
479
|
+
throw new Error(`Invalid --admin-history-limit "${value}". It must be a non-negative integer.`);
|
|
480
|
+
}
|
|
481
|
+
return limit;
|
|
482
|
+
}
|
|
483
|
+
function validateFakerSeed(value) {
|
|
484
|
+
const seed = value.trim() === "" ? Number.NaN : Number(value);
|
|
485
|
+
if (!Number.isInteger(seed)) {
|
|
486
|
+
throw new Error(`Invalid --seed-random "${value}". It must be a finite integer.`);
|
|
487
|
+
}
|
|
488
|
+
return seed;
|
|
489
|
+
}
|
|
490
|
+
function validateHostname(value) {
|
|
491
|
+
if (value === undefined)
|
|
492
|
+
return;
|
|
493
|
+
if (value.trim() === "") {
|
|
494
|
+
throw new Error("Invalid --hostname. The hostname must be a non-empty host, address or interface.");
|
|
495
|
+
}
|
|
496
|
+
return value;
|
|
497
|
+
}
|
|
498
|
+
function validateAdminToken(value) {
|
|
499
|
+
if (value === undefined)
|
|
500
|
+
return;
|
|
501
|
+
if (value === "" || /\s/.test(value)) {
|
|
502
|
+
throw new Error("Invalid --admin-token. The token must be non-empty and contain no whitespace.");
|
|
503
|
+
}
|
|
504
|
+
return value;
|
|
505
|
+
}
|
|
212
506
|
function parseCliArgs(args) {
|
|
213
507
|
const { values, positionals } = parseArgs({
|
|
214
508
|
args,
|
|
@@ -222,27 +516,45 @@ function parseCliArgs(args) {
|
|
|
222
516
|
errors: { type: "boolean", default: false },
|
|
223
517
|
watch: { type: "boolean", default: false },
|
|
224
518
|
admin: { type: "boolean", default: false },
|
|
519
|
+
"admin-token": { type: "string" },
|
|
520
|
+
"admin-history-limit": { type: "string" },
|
|
521
|
+
strict: { type: "boolean", default: false },
|
|
522
|
+
"refs-external": { type: "boolean", default: false },
|
|
523
|
+
"refs-allow-http": { type: "string" },
|
|
225
524
|
"seed-random": { type: "string" },
|
|
226
525
|
help: { type: "boolean", short: "h", default: false }
|
|
227
526
|
},
|
|
228
527
|
strict: true,
|
|
229
528
|
allowPositionals: true
|
|
230
529
|
});
|
|
530
|
+
if (positionals.length > 1) {
|
|
531
|
+
throw new Error(`Unexpected extra arguments: ${positionals.slice(1).join(", ")}. Pass exactly one spec path.`);
|
|
532
|
+
}
|
|
231
533
|
const spec = values.spec ?? positionals[0] ?? "";
|
|
232
534
|
return {
|
|
233
535
|
spec,
|
|
234
536
|
port: values.port ? validatePort(values.port) : undefined,
|
|
235
|
-
hostname: values.hostname,
|
|
537
|
+
hostname: validateHostname(values.hostname),
|
|
236
538
|
seed: values.seed,
|
|
237
539
|
cors: values.cors,
|
|
238
540
|
debug: values.debug,
|
|
239
541
|
errors: values.errors,
|
|
240
542
|
watch: values.watch,
|
|
241
543
|
admin: values.admin,
|
|
242
|
-
|
|
544
|
+
adminToken: validateAdminToken(values["admin-token"]),
|
|
545
|
+
adminHistoryLimit: values["admin-history-limit"] === undefined ? undefined : validateHistoryLimit(values["admin-history-limit"]),
|
|
546
|
+
strict: values.strict,
|
|
547
|
+
refsExternal: values["refs-external"],
|
|
548
|
+
refsAllowHttp: parseAllowedHosts(values["refs-allow-http"]),
|
|
549
|
+
fakerSeed: values["seed-random"] === undefined ? undefined : validateFakerSeed(values["seed-random"]),
|
|
243
550
|
help: values.help ?? false
|
|
244
551
|
};
|
|
245
552
|
}
|
|
553
|
+
function parseAllowedHosts(value) {
|
|
554
|
+
if (value === undefined)
|
|
555
|
+
return;
|
|
556
|
+
return value.split(",").map((host) => host.trim()).filter((host) => host.length > 0);
|
|
557
|
+
}
|
|
246
558
|
var USAGE = `Usage: schmock <spec> [options]
|
|
247
559
|
schmock --spec <path> [options]
|
|
248
560
|
|
|
@@ -251,30 +563,52 @@ Options:
|
|
|
251
563
|
--port <number> Port to listen on (default: 3000)
|
|
252
564
|
--hostname <host> Hostname to bind to (default: 127.0.0.1)
|
|
253
565
|
--seed <path> JSON file with seed data
|
|
254
|
-
--cors Enable CORS for
|
|
566
|
+
--cors Enable CORS for mock responses (never for /schmock-admin/*)
|
|
255
567
|
--debug Enable debug logging
|
|
256
568
|
--errors Enable request body validation against spec
|
|
257
569
|
--watch Watch spec file and hot-reload on changes
|
|
258
570
|
--admin Enable /schmock-admin/* introspection endpoints
|
|
571
|
+
--admin-token <token>
|
|
572
|
+
Bearer token required by /schmock-admin/*. Generated and
|
|
573
|
+
printed to stderr when --admin is set without it.
|
|
574
|
+
--admin-history-limit <n>
|
|
575
|
+
Requests retained for /schmock-admin/history (default: 500).
|
|
576
|
+
Without --admin no history is retained at all.
|
|
577
|
+
--strict Validate the spec against the OpenAPI schema at startup
|
|
578
|
+
--refs-external Resolve $refs to files outside the spec document
|
|
579
|
+
--refs-allow-http <hosts>
|
|
580
|
+
Also resolve http(s) $refs, limited to this comma-separated
|
|
581
|
+
host list (empty list = any public host). Requires
|
|
582
|
+
--refs-external.
|
|
259
583
|
--seed-random <n> Seed for deterministic random generation
|
|
260
584
|
-h, --help Show this help message
|
|
261
585
|
`;
|
|
262
586
|
var WATCH_DEBOUNCE_MS = 500;
|
|
263
|
-
async function
|
|
264
|
-
const
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
current.server.close(() => resolve());
|
|
270
|
-
});
|
|
271
|
-
return listenWithMock(nextOptions, replacementMock);
|
|
587
|
+
async function reloadMock(holder, options) {
|
|
588
|
+
const previous = holder.mock;
|
|
589
|
+
holder.mock = await createCliMock(options);
|
|
590
|
+
try {
|
|
591
|
+
previous.reset();
|
|
592
|
+
} catch {}
|
|
272
593
|
}
|
|
273
|
-
function startWatch(specPath, options,
|
|
594
|
+
function startWatch(specPath, options, holder, address) {
|
|
274
595
|
let debounceTimer;
|
|
275
596
|
let closed = false;
|
|
276
597
|
let reloadQueue = Promise.resolve();
|
|
277
|
-
const
|
|
598
|
+
const resolvedSpec = resolvePath(specPath);
|
|
599
|
+
const specDirectory = dirname(resolvedSpec);
|
|
600
|
+
const specName = basename(resolvedSpec);
|
|
601
|
+
const isSpecEvent = (filename) => {
|
|
602
|
+
if (filename == null)
|
|
603
|
+
return true;
|
|
604
|
+
const name = basename(filename.toString());
|
|
605
|
+
return name === specName || name === basename(specDirectory);
|
|
606
|
+
};
|
|
607
|
+
const watcher = watch(specDirectory, (_event, filename) => {
|
|
608
|
+
if (closed)
|
|
609
|
+
return;
|
|
610
|
+
if (!isSpecEvent(filename))
|
|
611
|
+
return;
|
|
278
612
|
if (debounceTimer)
|
|
279
613
|
clearTimeout(debounceTimer);
|
|
280
614
|
debounceTimer = setTimeout(() => {
|
|
@@ -285,32 +619,37 @@ function startWatch(specPath, options, getCurrentServer, onReload) {
|
|
|
285
619
|
Spec changed, reloading...
|
|
286
620
|
`);
|
|
287
621
|
try {
|
|
288
|
-
|
|
289
|
-
if (closed)
|
|
290
|
-
newServer.close();
|
|
622
|
+
await reloadMock(holder, options);
|
|
623
|
+
if (closed)
|
|
291
624
|
return;
|
|
292
|
-
}
|
|
293
|
-
onReload(newServer);
|
|
294
|
-
process.stderr.write(`Schmock server reloaded on http://${newServer.hostname}:${newServer.port}
|
|
625
|
+
process.stderr.write(`Schmock server reloaded on http://${address.hostname}:${address.port}
|
|
295
626
|
`);
|
|
296
627
|
} catch (err) {
|
|
628
|
+
if (closed)
|
|
629
|
+
return;
|
|
297
630
|
process.stderr.write(`Reload failed: ${err instanceof Error ? err.message : String(err)}
|
|
298
631
|
`);
|
|
299
632
|
}
|
|
300
633
|
});
|
|
301
634
|
}, WATCH_DEBOUNCE_MS);
|
|
302
635
|
});
|
|
636
|
+
watcher.on("error", (error) => {
|
|
637
|
+
process.stderr.write(`Spec watch error: ${error instanceof Error ? error.message : String(error)}
|
|
638
|
+
`);
|
|
639
|
+
});
|
|
303
640
|
return {
|
|
304
|
-
close() {
|
|
641
|
+
async close() {
|
|
305
642
|
closed = true;
|
|
306
643
|
watcher.close();
|
|
307
644
|
if (debounceTimer)
|
|
308
645
|
clearTimeout(debounceTimer);
|
|
646
|
+
await reloadQueue;
|
|
309
647
|
}
|
|
310
648
|
};
|
|
311
649
|
}
|
|
312
650
|
async function run(args) {
|
|
313
|
-
const
|
|
651
|
+
const parsed = parseCliArgs(args);
|
|
652
|
+
const options = { ...parsed, adminToken: resolveAdminToken(parsed) };
|
|
314
653
|
if (options.help) {
|
|
315
654
|
process.stderr.write(USAGE);
|
|
316
655
|
return;
|
|
@@ -323,7 +662,7 @@ async function run(args) {
|
|
|
323
662
|
process.exitCode = 1;
|
|
324
663
|
return;
|
|
325
664
|
}
|
|
326
|
-
|
|
665
|
+
const cliServer = await createCliServer(options);
|
|
327
666
|
process.stderr.write(`Schmock server running on http://${cliServer.hostname}:${cliServer.port}
|
|
328
667
|
`);
|
|
329
668
|
process.stderr.write(`Spec: ${options.spec}
|
|
@@ -335,24 +674,49 @@ async function run(args) {
|
|
|
335
674
|
if (options.admin) {
|
|
336
675
|
process.stderr.write(`Admin: enabled (/schmock-admin/*)
|
|
337
676
|
`);
|
|
677
|
+
process.stderr.write(`Admin token: ${options.adminToken}
|
|
678
|
+
`);
|
|
679
|
+
if (!isLoopbackHost(cliServer.hostname)) {
|
|
680
|
+
process.stderr.write(`WARNING: the admin API is enabled and bound to ${cliServer.hostname}, which is reachable from other hosts.
|
|
681
|
+
` + `WARNING: anyone who can reach this port and the admin token can read recorded requests and reset the mock.
|
|
682
|
+
`);
|
|
683
|
+
}
|
|
338
684
|
}
|
|
339
|
-
let watchHandle;
|
|
340
685
|
if (options.watch) {
|
|
341
|
-
watchHandle = startWatch(options.spec, options, () => cliServer, (newServer) => {
|
|
342
|
-
cliServer = newServer;
|
|
343
|
-
});
|
|
344
686
|
process.stderr.write(`Watch: enabled
|
|
345
687
|
`);
|
|
346
688
|
}
|
|
347
|
-
|
|
348
|
-
|
|
689
|
+
return new Promise((resolveRun, rejectRun) => {
|
|
690
|
+
let shuttingDown = false;
|
|
691
|
+
let repeatReported = false;
|
|
692
|
+
const shutdown = () => {
|
|
693
|
+
if (shuttingDown) {
|
|
694
|
+
if (!repeatReported) {
|
|
695
|
+
repeatReported = true;
|
|
696
|
+
process.stderr.write(`Shutdown already in progress; waiting for in-flight requests...
|
|
697
|
+
`);
|
|
698
|
+
}
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
shuttingDown = true;
|
|
702
|
+
process.stderr.write(`
|
|
349
703
|
Shutting down...
|
|
350
704
|
`);
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
705
|
+
const release = () => {
|
|
706
|
+
process.off("SIGINT", shutdown);
|
|
707
|
+
process.off("SIGTERM", shutdown);
|
|
708
|
+
};
|
|
709
|
+
cliServer.close().then((value) => {
|
|
710
|
+
release();
|
|
711
|
+
resolveRun(value);
|
|
712
|
+
}, (error) => {
|
|
713
|
+
release();
|
|
714
|
+
rejectRun(error);
|
|
715
|
+
});
|
|
716
|
+
};
|
|
717
|
+
process.on("SIGINT", shutdown);
|
|
718
|
+
process.on("SIGTERM", shutdown);
|
|
719
|
+
});
|
|
356
720
|
}
|
|
357
721
|
|
|
358
722
|
// src/bin.ts
|