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