@schmock/cli 2.4.1 → 2.6.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/dist/admin.d.ts +41 -0
- package/dist/args.d.ts +17 -0
- package/dist/bin.d.ts +0 -1
- package/dist/bin.js +492 -418
- package/dist/cli.d.ts +4 -73
- package/dist/cors.d.ts +30 -0
- package/dist/index.d.ts +0 -1
- package/dist/index.js +492 -418
- package/dist/request.d.ts +38 -0
- package/dist/seed-manifest.d.ts +15 -0
- package/dist/server.d.ts +14 -0
- package/dist/types.d.ts +28 -0
- package/dist/watch.d.ts +46 -0
- package/package.json +5 -5
- package/dist/bin.d.ts.map +0 -1
- package/dist/cli.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
package/dist/bin.js
CHANGED
|
@@ -1,126 +1,14 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
// src/
|
|
3
|
+
// src/admin.ts
|
|
4
4
|
import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
dirname,
|
|
10
|
-
isAbsolute,
|
|
11
|
-
relative,
|
|
12
|
-
resolve as resolvePath,
|
|
13
|
-
sep
|
|
14
|
-
} from "node:path";
|
|
15
|
-
import { parseArgs } from "node:util";
|
|
16
|
-
import {
|
|
17
|
-
collectBody,
|
|
18
|
-
HTTP_METHODS,
|
|
19
|
-
HttpIngressError,
|
|
20
|
-
isHttpMethod,
|
|
21
|
-
normalizeResponse,
|
|
22
|
-
parseNodeHeaders,
|
|
23
|
-
parseNodeQuery,
|
|
24
|
-
ResourceLimitError,
|
|
25
|
-
schmock,
|
|
26
|
-
writeRejectedSchmockResponse,
|
|
27
|
-
writeSchmockResponse
|
|
28
|
-
} from "@schmock/core";
|
|
29
|
-
import { MAX_SEED_MANIFEST_BYTES, openapi } from "@schmock/openapi";
|
|
30
|
-
var MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024;
|
|
31
|
-
var SHUTDOWN_GRACE_MS = 5000;
|
|
5
|
+
import { SchmockError, SENSITIVE_HEADER_NAMES } from "@schmock/core";
|
|
6
|
+
|
|
7
|
+
// src/cors.ts
|
|
8
|
+
import { HTTP_METHODS } from "@schmock/core";
|
|
32
9
|
var ALLOWED_METHODS = HTTP_METHODS.join(", ");
|
|
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
10
|
var DEFAULT_ALLOWED_REQUEST_HEADERS = "Content-Type, Authorization";
|
|
60
11
|
var REQUEST_HEADER_LIST = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+(?:[ \t]*,[ \t]*[!#$%&'*+\-.^_`|~0-9A-Za-z]+)*$/;
|
|
61
|
-
|
|
62
|
-
class CliHttpError extends Error {
|
|
63
|
-
status;
|
|
64
|
-
code;
|
|
65
|
-
headers;
|
|
66
|
-
constructor(status, code, message, headers = {}) {
|
|
67
|
-
super(message);
|
|
68
|
-
this.status = status;
|
|
69
|
-
this.code = code;
|
|
70
|
-
this.headers = headers;
|
|
71
|
-
this.name = "CliHttpError";
|
|
72
|
-
}
|
|
73
|
-
}
|
|
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;
|
|
89
|
-
}
|
|
90
|
-
function loadSeedFile(seedPath) {
|
|
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
|
-
}
|
|
104
|
-
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
105
|
-
throw new Error(`Seed file must contain a JSON object, got: ${Array.isArray(parsed) ? "array" : typeof parsed}`);
|
|
106
|
-
}
|
|
107
|
-
const result = {};
|
|
108
|
-
for (const [key, value] of Object.entries(parsed)) {
|
|
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)) {
|
|
114
|
-
result[key] = value;
|
|
115
|
-
} else {
|
|
116
|
-
throw new Error(`Seed entry "${key}" must be an array, a file path, or { "count": <number> }`);
|
|
117
|
-
}
|
|
118
|
-
}
|
|
119
|
-
return result;
|
|
120
|
-
}
|
|
121
|
-
function isAdminPath(admin, path) {
|
|
122
|
-
return admin && path.startsWith(ADMIN_PATH_PREFIX);
|
|
123
|
-
}
|
|
124
12
|
function singleHeader(value) {
|
|
125
13
|
return typeof value === "string" ? value : undefined;
|
|
126
14
|
}
|
|
@@ -142,6 +30,22 @@ function corsHeadersFor(req) {
|
|
|
142
30
|
function isCorsPreflight(req, method) {
|
|
143
31
|
return method === "OPTIONS" && singleHeader(req.headers.origin) !== undefined && singleHeader(req.headers["access-control-request-method"]) !== undefined;
|
|
144
32
|
}
|
|
33
|
+
|
|
34
|
+
// src/admin.ts
|
|
35
|
+
var ADMIN_PATH_PREFIX = "/schmock-admin/";
|
|
36
|
+
var DEFAULT_ADMIN_HISTORY_LIMIT = 500;
|
|
37
|
+
var REDACTED = "[redacted]";
|
|
38
|
+
var CREDENTIAL_NAME = /(?:^|[-_])(?:api[-_]?key|key|token|secret|password)$/;
|
|
39
|
+
function isSensitiveHeader(name) {
|
|
40
|
+
const lower = name.toLowerCase();
|
|
41
|
+
return SENSITIVE_HEADER_NAMES.has(lower) || CREDENTIAL_NAME.test(lower);
|
|
42
|
+
}
|
|
43
|
+
function isSensitiveQueryKey(name) {
|
|
44
|
+
return CREDENTIAL_NAME.test(name.toLowerCase());
|
|
45
|
+
}
|
|
46
|
+
function isAdminPath(admin, path) {
|
|
47
|
+
return admin && path.startsWith(ADMIN_PATH_PREFIX);
|
|
48
|
+
}
|
|
145
49
|
function presentedAdminToken(req) {
|
|
146
50
|
const authorization = singleHeader(req.headers.authorization);
|
|
147
51
|
const bearer = authorization ? /^bearer\s+(\S+)$/i.exec(authorization.trim()) : null;
|
|
@@ -154,336 +58,128 @@ function tokensMatch(expected, presented) {
|
|
|
154
58
|
const presentedDigest = createHash("sha256").update(presented).digest();
|
|
155
59
|
return timingSafeEqual(expectedDigest, presentedDigest);
|
|
156
60
|
}
|
|
157
|
-
function
|
|
61
|
+
function adminResponse(status, body, extraHeaders = {}) {
|
|
158
62
|
return {
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
63
|
+
status,
|
|
64
|
+
body,
|
|
65
|
+
headers: {
|
|
66
|
+
"content-type": "application/json",
|
|
67
|
+
"cache-control": "no-store",
|
|
68
|
+
vary: "origin",
|
|
69
|
+
...extraHeaders
|
|
70
|
+
}
|
|
163
71
|
};
|
|
164
72
|
}
|
|
165
|
-
function
|
|
73
|
+
function refuseAdminRequest(req, adminToken) {
|
|
166
74
|
if (req.headers.origin !== undefined) {
|
|
167
|
-
|
|
168
|
-
res.end(JSON.stringify({
|
|
75
|
+
return adminResponse(403, JSON.stringify({
|
|
169
76
|
error: "Admin API refuses browser-originated requests",
|
|
170
77
|
code: "FORBIDDEN"
|
|
171
78
|
}));
|
|
172
|
-
return true;
|
|
173
79
|
}
|
|
174
80
|
const presented = presentedAdminToken(req);
|
|
175
81
|
if (adminToken === undefined || adminToken === "" || presented === undefined || !tokensMatch(adminToken, presented)) {
|
|
176
|
-
|
|
177
|
-
"www-authenticate": 'Bearer realm="schmock-admin"'
|
|
178
|
-
}));
|
|
179
|
-
res.end(JSON.stringify({
|
|
82
|
+
return adminResponse(401, JSON.stringify({
|
|
180
83
|
error: "Admin API requires a valid bearer token",
|
|
181
84
|
code: "UNAUTHORIZED"
|
|
182
|
-
}));
|
|
183
|
-
return true;
|
|
85
|
+
}), { "www-authenticate": 'Bearer realm="schmock-admin"' });
|
|
184
86
|
}
|
|
185
|
-
return
|
|
87
|
+
return;
|
|
186
88
|
}
|
|
187
|
-
function
|
|
89
|
+
function isObjectLike(value) {
|
|
188
90
|
return typeof value === "object" && value !== null;
|
|
189
91
|
}
|
|
190
|
-
function
|
|
191
|
-
if (!
|
|
92
|
+
function redactEntries(entries, isSensitive) {
|
|
93
|
+
if (!isObjectLike(entries))
|
|
192
94
|
return {};
|
|
193
95
|
const redacted = {};
|
|
194
|
-
for (const [name, value] of Object.entries(
|
|
195
|
-
redacted[name] =
|
|
96
|
+
for (const [name, value] of Object.entries(entries)) {
|
|
97
|
+
redacted[name] = isSensitive(name) ? REDACTED : value;
|
|
196
98
|
}
|
|
197
99
|
return redacted;
|
|
198
100
|
}
|
|
199
101
|
function redactHistory(records) {
|
|
200
102
|
return records.map((record) => ({
|
|
201
103
|
...record,
|
|
202
|
-
|
|
104
|
+
query: redactEntries(record.query, isSensitiveQueryKey),
|
|
105
|
+
headers: redactEntries(record.headers, isSensitiveHeader)
|
|
203
106
|
}));
|
|
204
107
|
}
|
|
205
|
-
function
|
|
108
|
+
function dispatchAdminRequest({
|
|
109
|
+
method,
|
|
110
|
+
path,
|
|
111
|
+
mock
|
|
112
|
+
}) {
|
|
206
113
|
const route = path.replace(ADMIN_PATH_PREFIX, "");
|
|
207
114
|
if (method === "GET" && route === "routes") {
|
|
208
|
-
|
|
209
|
-
res.end(JSON.stringify(mock.getRoutes()));
|
|
210
|
-
return;
|
|
115
|
+
return adminResponse(200, JSON.stringify(mock.getRoutes()));
|
|
211
116
|
}
|
|
212
117
|
if (method === "GET" && route === "state") {
|
|
213
|
-
|
|
214
|
-
res.end(JSON.stringify(mock.getState()));
|
|
215
|
-
return;
|
|
118
|
+
return adminResponse(200, JSON.stringify(mock.getState()));
|
|
216
119
|
}
|
|
217
120
|
if (method === "POST" && route === "reset") {
|
|
218
121
|
mock.resetHistory();
|
|
219
122
|
mock.resetState();
|
|
220
|
-
|
|
221
|
-
res.end();
|
|
222
|
-
return;
|
|
123
|
+
return adminResponse(204);
|
|
223
124
|
}
|
|
224
125
|
if (method === "GET" && route === "history") {
|
|
225
|
-
|
|
226
|
-
res.end(JSON.stringify(redactHistory(mock.history())));
|
|
227
|
-
return;
|
|
126
|
+
return adminResponse(200, JSON.stringify(redactHistory(mock.history())));
|
|
228
127
|
}
|
|
229
|
-
|
|
230
|
-
res.end(JSON.stringify({ error: "Unknown admin endpoint", code: "NOT_FOUND" }));
|
|
128
|
+
return adminResponse(404, JSON.stringify({ error: "Unknown admin endpoint", code: "NOT_FOUND" }));
|
|
231
129
|
}
|
|
232
|
-
function
|
|
233
|
-
|
|
234
|
-
if (!host) {
|
|
235
|
-
throw new CliHttpError(400, "BAD_REQUEST", "Missing Host header");
|
|
236
|
-
}
|
|
237
|
-
try {
|
|
238
|
-
return new URL(req.url ?? "/", `http://${host}`);
|
|
239
|
-
} catch {
|
|
240
|
-
throw new CliHttpError(400, "BAD_REQUEST", "Malformed request target");
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
function parseRequestMethod(req) {
|
|
244
|
-
const method = (req.method ?? "GET").toUpperCase();
|
|
245
|
-
if (!isHttpMethod(method)) {
|
|
246
|
-
throw new CliHttpError(405, "METHOD_NOT_ALLOWED", `Unsupported HTTP method: ${method}`, { allow: ALLOWED_METHODS });
|
|
247
|
-
}
|
|
248
|
-
return method;
|
|
249
|
-
}
|
|
250
|
-
function toCliHttpError(error) {
|
|
251
|
-
if (error instanceof CliHttpError)
|
|
252
|
-
return error;
|
|
253
|
-
if (error instanceof HttpIngressError) {
|
|
254
|
-
return new CliHttpError(error.status, error.code, error.message);
|
|
255
|
-
}
|
|
256
|
-
return new CliHttpError(500, "SERVER_ERROR", error instanceof Error ? error.message : "Internal Server Error");
|
|
130
|
+
function answerAdminRequest(request) {
|
|
131
|
+
return refuseAdminRequest(request.req, request.adminToken) ?? dispatchAdminRequest(request);
|
|
257
132
|
}
|
|
258
|
-
|
|
259
|
-
|
|
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;
|
|
266
|
-
try {
|
|
267
|
-
const url = parseRequestUrl(req);
|
|
268
|
-
const path = url.pathname;
|
|
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));
|
|
274
|
-
res.end();
|
|
275
|
-
return;
|
|
276
|
-
}
|
|
277
|
-
if (adminRequest) {
|
|
278
|
-
if (denyAdminRequest(req, res, options.adminToken))
|
|
279
|
-
return;
|
|
280
|
-
handleAdminRequest(method, path, mock, res, adminResponseHeaders());
|
|
281
|
-
return;
|
|
282
|
-
}
|
|
283
|
-
admission = acquireRequestAdmission(mock);
|
|
284
|
-
const handleRequest = admission?.handle ?? ((admittedMethod, requestPath, requestOptions) => mock.handle(admittedMethod, requestPath, requestOptions));
|
|
285
|
-
const headers = parseNodeHeaders(req);
|
|
286
|
-
const query = parseNodeQuery(url);
|
|
287
|
-
const body = await collectBody(req, headers, MAX_REQUEST_BODY_SIZE);
|
|
288
|
-
const schmockResponse = await handleRequest(method, path, {
|
|
289
|
-
headers,
|
|
290
|
-
body,
|
|
291
|
-
query,
|
|
292
|
-
signal: abortController.signal
|
|
293
|
-
});
|
|
294
|
-
const extraHeaders = options.cors ? corsHeadersFor(req) : undefined;
|
|
295
|
-
writeSchmockResponse(res, schmockResponse, extraHeaders);
|
|
296
|
-
} catch (error) {
|
|
297
|
-
const cliError = toCliHttpError(error);
|
|
298
|
-
try {
|
|
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();
|
|
321
|
-
}
|
|
322
|
-
} catch {
|
|
323
|
-
res.destroy();
|
|
324
|
-
}
|
|
325
|
-
} finally {
|
|
326
|
-
req.off("aborted", abortRequest);
|
|
327
|
-
res.off("close", abortRequest);
|
|
328
|
-
admission?.release();
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
async function createCliMock(options) {
|
|
332
|
-
const mock = schmock({
|
|
333
|
-
debug: options.debug,
|
|
334
|
-
state: {},
|
|
335
|
-
maxHistorySize: options.admin ? options.adminHistoryLimit ?? DEFAULT_ADMIN_HISTORY_LIMIT : 0
|
|
336
|
-
});
|
|
337
|
-
const openapiOptions = {
|
|
338
|
-
spec: options.spec,
|
|
339
|
-
fakerSeed: options.fakerSeed,
|
|
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
|
-
}
|
|
347
|
-
};
|
|
348
|
-
if (options.seed) {
|
|
349
|
-
openapiOptions.seed = loadSeedFile(options.seed);
|
|
350
|
-
}
|
|
351
|
-
const plugin = await openapi(openapiOptions);
|
|
352
|
-
mock.pipe(plugin);
|
|
353
|
-
return mock;
|
|
354
|
-
}
|
|
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
|
-
}
|
|
386
|
-
const hostname = options.hostname ?? "127.0.0.1";
|
|
387
|
-
const port = options.port ?? 3000;
|
|
388
|
-
const cors = options.cors ?? false;
|
|
389
|
-
const graceMs = options.shutdownGraceMs ?? SHUTDOWN_GRACE_MS;
|
|
390
|
-
const admin = options.admin ?? false;
|
|
391
|
-
const adminToken = admin ? options.adminToken : undefined;
|
|
392
|
-
const httpServer = createServer((req, res) => {
|
|
393
|
-
handleCliRequest(req, res, holder.mock, { admin, cors, adminToken });
|
|
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
|
-
};
|
|
413
|
-
return new Promise((resolve, reject) => {
|
|
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);
|
|
420
|
-
httpServer.listen(port, hostname, () => {
|
|
421
|
-
httpServer.on("error", reportServerError);
|
|
422
|
-
httpServer.off("error", onListenError);
|
|
423
|
-
const addr = httpServer.address();
|
|
424
|
-
const actualPort = addr !== null && typeof addr === "object" ? addr.port : port;
|
|
425
|
-
resolve({
|
|
426
|
-
server: httpServer,
|
|
427
|
-
port: actualPort,
|
|
428
|
-
hostname,
|
|
429
|
-
adminToken,
|
|
430
|
-
close,
|
|
431
|
-
onClose(cleanup) {
|
|
432
|
-
cleanups.push(cleanup);
|
|
433
|
-
}
|
|
434
|
-
});
|
|
435
|
-
});
|
|
436
|
-
});
|
|
133
|
+
function isUsableAdminToken(value) {
|
|
134
|
+
return value !== "" && !/\s/.test(value);
|
|
437
135
|
}
|
|
438
136
|
function resolveAdminToken(options) {
|
|
439
137
|
if (!options.admin)
|
|
440
138
|
return;
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
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());
|
|
139
|
+
if (options.adminToken === undefined)
|
|
140
|
+
return randomUUID();
|
|
141
|
+
if (!isUsableAdminToken(options.adminToken)) {
|
|
142
|
+
throw new SchmockError("Invalid admin token. The token must be non-empty and contain no whitespace.", "INVALID_CONFIG", { option: "adminToken" });
|
|
459
143
|
}
|
|
460
|
-
return
|
|
144
|
+
return options.adminToken;
|
|
461
145
|
}
|
|
146
|
+
|
|
147
|
+
// src/args.ts
|
|
148
|
+
import { parseArgs } from "node:util";
|
|
149
|
+
import { SchmockError as SchmockError2 } from "@schmock/core";
|
|
462
150
|
function isLoopbackHost(hostname) {
|
|
463
|
-
const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "");
|
|
151
|
+
const host = hostname.trim().toLowerCase().replace(/^\[|\]$/g, "").replace(/^::ffff:(?=\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$)/, "");
|
|
464
152
|
if (host === "localhost" || host === "::1" || host === "0:0:0:0:0:0:0:1") {
|
|
465
153
|
return true;
|
|
466
154
|
}
|
|
467
155
|
return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(host);
|
|
468
156
|
}
|
|
157
|
+
function invalidFlag(message, context) {
|
|
158
|
+
return new SchmockError2(message, "INVALID_CONFIG", context);
|
|
159
|
+
}
|
|
160
|
+
function parseDecimal(value, { signed = false } = {}) {
|
|
161
|
+
const trimmed = value.trim();
|
|
162
|
+
const pattern = signed ? /^-?\d+$/ : /^\d+$/;
|
|
163
|
+
return pattern.test(trimmed) ? Number(trimmed) : Number.NaN;
|
|
164
|
+
}
|
|
469
165
|
function validatePort(value) {
|
|
470
|
-
const port =
|
|
166
|
+
const port = parseDecimal(value);
|
|
471
167
|
if (!Number.isInteger(port) || port < 0 || port > 65535) {
|
|
472
|
-
throw
|
|
168
|
+
throw invalidFlag(`Invalid port "${value}". Port must be an integer between 0 and 65535.`, { flag: "--port", value });
|
|
473
169
|
}
|
|
474
170
|
return port;
|
|
475
171
|
}
|
|
476
172
|
function validateHistoryLimit(value) {
|
|
477
|
-
const limit =
|
|
173
|
+
const limit = parseDecimal(value);
|
|
478
174
|
if (!Number.isInteger(limit) || limit < 0) {
|
|
479
|
-
throw
|
|
175
|
+
throw invalidFlag(`Invalid --admin-history-limit "${value}". It must be a non-negative integer.`, { flag: "--admin-history-limit", value });
|
|
480
176
|
}
|
|
481
177
|
return limit;
|
|
482
178
|
}
|
|
483
179
|
function validateFakerSeed(value) {
|
|
484
|
-
const seed = value
|
|
180
|
+
const seed = parseDecimal(value, { signed: true });
|
|
485
181
|
if (!Number.isInteger(seed)) {
|
|
486
|
-
throw
|
|
182
|
+
throw invalidFlag(`Invalid --seed-random "${value}". It must be a finite integer.`, { flag: "--seed-random", value });
|
|
487
183
|
}
|
|
488
184
|
return seed;
|
|
489
185
|
}
|
|
@@ -491,18 +187,23 @@ function validateHostname(value) {
|
|
|
491
187
|
if (value === undefined)
|
|
492
188
|
return;
|
|
493
189
|
if (value.trim() === "") {
|
|
494
|
-
throw
|
|
190
|
+
throw invalidFlag("Invalid --hostname. The hostname must be a non-empty host, address or interface.", { flag: "--hostname", value });
|
|
495
191
|
}
|
|
496
192
|
return value;
|
|
497
193
|
}
|
|
498
194
|
function validateAdminToken(value) {
|
|
499
195
|
if (value === undefined)
|
|
500
196
|
return;
|
|
501
|
-
if (
|
|
502
|
-
throw
|
|
197
|
+
if (!isUsableAdminToken(value)) {
|
|
198
|
+
throw invalidFlag("Invalid --admin-token. The token must be non-empty and contain no whitespace.", { flag: "--admin-token" });
|
|
503
199
|
}
|
|
504
200
|
return value;
|
|
505
201
|
}
|
|
202
|
+
function parseAllowedHosts(value) {
|
|
203
|
+
if (value === undefined)
|
|
204
|
+
return;
|
|
205
|
+
return value.split(",").map((host) => host.trim()).filter((host) => host.length > 0);
|
|
206
|
+
}
|
|
506
207
|
function parseCliArgs(args) {
|
|
507
208
|
const { values, positionals } = parseArgs({
|
|
508
209
|
args,
|
|
@@ -528,12 +229,17 @@ function parseCliArgs(args) {
|
|
|
528
229
|
allowPositionals: true
|
|
529
230
|
});
|
|
530
231
|
if (positionals.length > 1) {
|
|
531
|
-
throw
|
|
232
|
+
throw invalidFlag(`Unexpected extra arguments: ${positionals.slice(1).join(", ")}. Pass exactly one spec path.`, { flag: "<spec>" });
|
|
532
233
|
}
|
|
533
234
|
const spec = values.spec ?? positionals[0] ?? "";
|
|
235
|
+
if (values["admin-token"] !== undefined && !values.admin) {
|
|
236
|
+
throw invalidFlag("--admin-token requires --admin.", {
|
|
237
|
+
flag: "--admin-token"
|
|
238
|
+
});
|
|
239
|
+
}
|
|
534
240
|
return {
|
|
535
241
|
spec,
|
|
536
|
-
port: values.port ? validatePort(values.port)
|
|
242
|
+
port: values.port === undefined ? undefined : validatePort(values.port),
|
|
537
243
|
hostname: validateHostname(values.hostname),
|
|
538
244
|
seed: values.seed,
|
|
539
245
|
cors: values.cors,
|
|
@@ -550,11 +256,6 @@ function parseCliArgs(args) {
|
|
|
550
256
|
help: values.help ?? false
|
|
551
257
|
};
|
|
552
258
|
}
|
|
553
|
-
function parseAllowedHosts(value) {
|
|
554
|
-
if (value === undefined)
|
|
555
|
-
return;
|
|
556
|
-
return value.split(",").map((host) => host.trim()).filter((host) => host.length > 0);
|
|
557
|
-
}
|
|
558
259
|
var USAGE = `Usage: schmock <spec> [options]
|
|
559
260
|
schmock --spec <path> [options]
|
|
560
261
|
|
|
@@ -583,32 +284,216 @@ Options:
|
|
|
583
284
|
--seed-random <n> Seed for deterministic random generation
|
|
584
285
|
-h, --help Show this help message
|
|
585
286
|
`;
|
|
287
|
+
|
|
288
|
+
// src/server.ts
|
|
289
|
+
import { createServer } from "node:http";
|
|
290
|
+
import { SchmockError as SchmockError4, schmock } from "@schmock/core";
|
|
291
|
+
import { openapi } from "@schmock/openapi";
|
|
292
|
+
|
|
293
|
+
// src/request.ts
|
|
294
|
+
import { serveNodeRequest } from "@schmock/core";
|
|
295
|
+
import { acquireRequestAdmission } from "@schmock/core/adapter";
|
|
296
|
+
function admitRequest(mock) {
|
|
297
|
+
const noRelease = () => {};
|
|
298
|
+
let admission;
|
|
299
|
+
try {
|
|
300
|
+
admission = acquireRequestAdmission(mock);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
return { handle: () => Promise.reject(error), release: noRelease };
|
|
303
|
+
}
|
|
304
|
+
return admission ?? {
|
|
305
|
+
handle: (method, path, options) => mock.handle(method, path, options),
|
|
306
|
+
release: noRelease
|
|
307
|
+
};
|
|
308
|
+
}
|
|
309
|
+
function handleCliRequest(req, res, context) {
|
|
310
|
+
const { mock, admin, cors, adminToken } = context;
|
|
311
|
+
const admission = admitRequest(mock);
|
|
312
|
+
const answerBeforeBody = (method, path) => {
|
|
313
|
+
const adminRequest = isAdminPath(admin, path);
|
|
314
|
+
if (cors && !adminRequest && isCorsPreflight(req, method)) {
|
|
315
|
+
return { status: 204, body: undefined, headers: {} };
|
|
316
|
+
}
|
|
317
|
+
if (adminRequest) {
|
|
318
|
+
return answerAdminRequest({ req, method, path, mock, adminToken });
|
|
319
|
+
}
|
|
320
|
+
return;
|
|
321
|
+
};
|
|
322
|
+
return serveNodeRequest(req, res, {
|
|
323
|
+
handle: (method, path, options) => admission.handle(method, path, options),
|
|
324
|
+
answerBeforeBody,
|
|
325
|
+
extraHeaders: ({ path }) => cors && !(path !== undefined && isAdminPath(admin, path)) ? corsHeadersFor(req) : undefined
|
|
326
|
+
}).finally(() => admission.release());
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// src/seed-manifest.ts
|
|
330
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
331
|
+
import {
|
|
332
|
+
dirname,
|
|
333
|
+
isAbsolute,
|
|
334
|
+
relative,
|
|
335
|
+
resolve as resolvePath,
|
|
336
|
+
sep
|
|
337
|
+
} from "node:path";
|
|
338
|
+
import { ResourceLimitError, SchmockError as SchmockError3 } from "@schmock/core";
|
|
339
|
+
import { MAX_SEED_MANIFEST_BYTES } from "@schmock/openapi";
|
|
340
|
+
function invalidSeed(message, context = {}) {
|
|
341
|
+
return new SchmockError3(message, "OPENAPI_INVALID_OPTION", {
|
|
342
|
+
option: "seed",
|
|
343
|
+
...context
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
function isCountSource(value) {
|
|
347
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) && "count" in value && typeof value.count === "number";
|
|
348
|
+
}
|
|
349
|
+
function resolveSeedEntryPath(entry, key, baseDir) {
|
|
350
|
+
let real;
|
|
351
|
+
try {
|
|
352
|
+
real = realpathSync(resolvePath(baseDir, entry));
|
|
353
|
+
} catch {
|
|
354
|
+
throw invalidSeed(`Seed entry "${key}" points to a missing file: ${entry}`, {
|
|
355
|
+
resource: key
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
const rel = relative(baseDir, real);
|
|
359
|
+
if (rel === "" || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) {
|
|
360
|
+
throw invalidSeed(`Seed entry "${key}" must stay inside the seed manifest directory: ${entry}`, { resource: key });
|
|
361
|
+
}
|
|
362
|
+
return real;
|
|
363
|
+
}
|
|
364
|
+
function loadSeedFile(seedPath) {
|
|
365
|
+
const manifestPath = resolvePath(seedPath);
|
|
366
|
+
const { size } = statSync(manifestPath);
|
|
367
|
+
if (size > MAX_SEED_MANIFEST_BYTES) {
|
|
368
|
+
throw new ResourceLimitError(`seed manifest "${seedPath}"`, MAX_SEED_MANIFEST_BYTES, size);
|
|
369
|
+
}
|
|
370
|
+
const baseDir = realpathSync(dirname(manifestPath));
|
|
371
|
+
const raw = readFileSync(manifestPath, "utf-8");
|
|
372
|
+
let parsed;
|
|
373
|
+
try {
|
|
374
|
+
parsed = JSON.parse(raw);
|
|
375
|
+
} catch {
|
|
376
|
+
throw invalidSeed(`Seed file "${seedPath}" contains invalid JSON`);
|
|
377
|
+
}
|
|
378
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
379
|
+
throw invalidSeed(`Seed file must contain a JSON object, got: ${Array.isArray(parsed) ? "array" : typeof parsed}`);
|
|
380
|
+
}
|
|
381
|
+
const result = {};
|
|
382
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
383
|
+
if (Array.isArray(value)) {
|
|
384
|
+
result[key] = value;
|
|
385
|
+
} else if (typeof value === "string") {
|
|
386
|
+
result[key] = resolveSeedEntryPath(value, key, baseDir);
|
|
387
|
+
} else if (isCountSource(value)) {
|
|
388
|
+
result[key] = value;
|
|
389
|
+
} else {
|
|
390
|
+
throw invalidSeed(`Seed entry "${key}" must be an array, a file path, or { "count": <number> }`, { resource: key });
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
return result;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// src/watch.ts
|
|
397
|
+
import { realpathSync as realpathSync2, watch } from "node:fs";
|
|
398
|
+
import { basename, dirname as dirname2, relative as relative2, resolve as resolvePath2 } from "node:path";
|
|
586
399
|
var WATCH_DEBOUNCE_MS = 500;
|
|
587
|
-
async function reloadMock(holder,
|
|
400
|
+
async function reloadMock({ holder, rebuild }) {
|
|
588
401
|
const previous = holder.mock;
|
|
589
|
-
holder.mock = await
|
|
402
|
+
holder.mock = await rebuild();
|
|
590
403
|
try {
|
|
591
404
|
previous.reset();
|
|
592
405
|
} catch {}
|
|
593
406
|
}
|
|
594
|
-
|
|
407
|
+
var SCHEMA_EXTENSION = /\.(?:json|ya?ml)$/i;
|
|
408
|
+
function isSchemaSibling(name) {
|
|
409
|
+
return SCHEMA_EXTENSION.test(name) && !name.startsWith(".");
|
|
410
|
+
}
|
|
411
|
+
function seedEntryFiles(seedPath) {
|
|
412
|
+
try {
|
|
413
|
+
return Object.values(loadSeedFile(seedPath)).filter((source) => typeof source === "string");
|
|
414
|
+
} catch {
|
|
415
|
+
return [];
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
function realDirectory(directory) {
|
|
419
|
+
try {
|
|
420
|
+
return realpathSync2(directory);
|
|
421
|
+
} catch {
|
|
422
|
+
return directory;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function collectWatchTargets(options) {
|
|
426
|
+
const targets = new Map;
|
|
427
|
+
const matcherFor = (directory) => {
|
|
428
|
+
let matcher = targets.get(directory);
|
|
429
|
+
if (!matcher) {
|
|
430
|
+
matcher = { names: new Set, schemaSiblings: false };
|
|
431
|
+
targets.set(directory, matcher);
|
|
432
|
+
}
|
|
433
|
+
return matcher;
|
|
434
|
+
};
|
|
435
|
+
const addFile = (path) => {
|
|
436
|
+
matcherFor(dirname2(path)).names.add(basename(path));
|
|
437
|
+
};
|
|
438
|
+
const resolvedSpec = resolvePath2(options.spec);
|
|
439
|
+
addFile(resolvedSpec);
|
|
440
|
+
if (options.refsExternal) {
|
|
441
|
+
matcherFor(dirname2(resolvedSpec)).schemaSiblings = true;
|
|
442
|
+
}
|
|
443
|
+
if (options.seed !== undefined) {
|
|
444
|
+
const manifest = resolvePath2(options.seed);
|
|
445
|
+
const manifestDirectory = dirname2(manifest);
|
|
446
|
+
addFile(manifest);
|
|
447
|
+
const realManifestDirectory = realDirectory(manifestDirectory);
|
|
448
|
+
for (const entry of seedEntryFiles(options.seed)) {
|
|
449
|
+
addFile(resolvePath2(manifestDirectory, relative2(realManifestDirectory, entry)));
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return targets;
|
|
453
|
+
}
|
|
454
|
+
function matchesWatchTarget(matcher, directory, filename) {
|
|
455
|
+
if (filename == null)
|
|
456
|
+
return true;
|
|
457
|
+
const name = basename(filename.toString());
|
|
458
|
+
return matcher.names.has(name) || name === basename(directory) || matcher.schemaSiblings && isSchemaSibling(name);
|
|
459
|
+
}
|
|
460
|
+
function startWatch(input) {
|
|
461
|
+
const { options, url } = input;
|
|
595
462
|
let debounceTimer;
|
|
596
463
|
let closed = false;
|
|
597
464
|
let reloadQueue = Promise.resolve();
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
const
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
465
|
+
let targets = collectWatchTargets(options);
|
|
466
|
+
const watchers = new Map;
|
|
467
|
+
const reportWatchError = (error) => {
|
|
468
|
+
process.stderr.write(`Spec watch error: ${error instanceof Error ? error.message : String(error)}
|
|
469
|
+
`);
|
|
470
|
+
};
|
|
471
|
+
const closeWatchers = () => {
|
|
472
|
+
for (const watcher of watchers.values())
|
|
473
|
+
watcher.close();
|
|
474
|
+
watchers.clear();
|
|
606
475
|
};
|
|
607
|
-
const
|
|
476
|
+
const rearm = () => {
|
|
608
477
|
if (closed)
|
|
609
478
|
return;
|
|
610
|
-
|
|
611
|
-
|
|
479
|
+
targets = collectWatchTargets(options);
|
|
480
|
+
for (const [directory, watcher] of watchers) {
|
|
481
|
+
if (targets.has(directory))
|
|
482
|
+
continue;
|
|
483
|
+
watcher.close();
|
|
484
|
+
watchers.delete(directory);
|
|
485
|
+
}
|
|
486
|
+
for (const directory of targets.keys()) {
|
|
487
|
+
if (watchers.has(directory))
|
|
488
|
+
continue;
|
|
489
|
+
try {
|
|
490
|
+
watchers.set(directory, watchDirectory(directory));
|
|
491
|
+
} catch (error) {
|
|
492
|
+
reportWatchError(error);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
};
|
|
496
|
+
const scheduleReload = () => {
|
|
612
497
|
if (debounceTimer)
|
|
613
498
|
clearTimeout(debounceTimer);
|
|
614
499
|
debounceTimer = setTimeout(() => {
|
|
@@ -619,10 +504,10 @@ function startWatch(specPath, options, holder, address) {
|
|
|
619
504
|
Spec changed, reloading...
|
|
620
505
|
`);
|
|
621
506
|
try {
|
|
622
|
-
await reloadMock(
|
|
507
|
+
await reloadMock(input);
|
|
623
508
|
if (closed)
|
|
624
509
|
return;
|
|
625
|
-
process.stderr.write(`Schmock server reloaded on
|
|
510
|
+
process.stderr.write(`Schmock server reloaded on ${url} (state and request history reset)
|
|
626
511
|
`);
|
|
627
512
|
} catch (err) {
|
|
628
513
|
if (closed)
|
|
@@ -630,23 +515,200 @@ Spec changed, reloading...
|
|
|
630
515
|
process.stderr.write(`Reload failed: ${err instanceof Error ? err.message : String(err)}
|
|
631
516
|
`);
|
|
632
517
|
}
|
|
518
|
+
rearm();
|
|
633
519
|
});
|
|
634
520
|
}, WATCH_DEBOUNCE_MS);
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
521
|
+
};
|
|
522
|
+
function watchDirectory(directory) {
|
|
523
|
+
const watcher = watch(directory, (_event, filename) => {
|
|
524
|
+
if (closed)
|
|
525
|
+
return;
|
|
526
|
+
const matcher = targets.get(directory);
|
|
527
|
+
if (matcher === undefined)
|
|
528
|
+
return;
|
|
529
|
+
if (!matchesWatchTarget(matcher, directory, filename))
|
|
530
|
+
return;
|
|
531
|
+
scheduleReload();
|
|
532
|
+
});
|
|
533
|
+
watcher.on("error", reportWatchError);
|
|
534
|
+
return watcher;
|
|
535
|
+
}
|
|
536
|
+
try {
|
|
537
|
+
for (const directory of targets.keys()) {
|
|
538
|
+
watchers.set(directory, watchDirectory(directory));
|
|
539
|
+
}
|
|
540
|
+
} catch (error) {
|
|
541
|
+
closeWatchers();
|
|
542
|
+
throw error;
|
|
543
|
+
}
|
|
640
544
|
return {
|
|
641
545
|
async close() {
|
|
642
546
|
closed = true;
|
|
643
|
-
|
|
547
|
+
closeWatchers();
|
|
644
548
|
if (debounceTimer)
|
|
645
549
|
clearTimeout(debounceTimer);
|
|
646
550
|
await reloadQueue;
|
|
647
551
|
}
|
|
648
552
|
};
|
|
649
553
|
}
|
|
554
|
+
|
|
555
|
+
// src/server.ts
|
|
556
|
+
var SHUTDOWN_GRACE_MS = 5000;
|
|
557
|
+
async function createCliMock(options) {
|
|
558
|
+
const mock = schmock({
|
|
559
|
+
debug: options.debug,
|
|
560
|
+
state: {},
|
|
561
|
+
maxHistorySize: options.admin ? options.adminHistoryLimit ?? DEFAULT_ADMIN_HISTORY_LIMIT : 0
|
|
562
|
+
});
|
|
563
|
+
const openapiOptions = {
|
|
564
|
+
spec: options.spec,
|
|
565
|
+
fakerSeed: options.fakerSeed,
|
|
566
|
+
validateRequests: options.errors,
|
|
567
|
+
strict: options.strict,
|
|
568
|
+
refs: {
|
|
569
|
+
external: options.refsExternal ?? false,
|
|
570
|
+
allowHttp: options.refsAllowHttp !== undefined,
|
|
571
|
+
allowedHosts: options.refsAllowHttp
|
|
572
|
+
}
|
|
573
|
+
};
|
|
574
|
+
if (options.seed) {
|
|
575
|
+
openapiOptions.seed = loadSeedFile(options.seed);
|
|
576
|
+
}
|
|
577
|
+
const plugin = await openapi(openapiOptions);
|
|
578
|
+
mock.pipe(plugin);
|
|
579
|
+
return mock;
|
|
580
|
+
}
|
|
581
|
+
function boundedDelay(ms) {
|
|
582
|
+
return new Promise((resolve) => {
|
|
583
|
+
setTimeout(resolve, ms).unref();
|
|
584
|
+
});
|
|
585
|
+
}
|
|
586
|
+
function closeHttpServer({
|
|
587
|
+
httpServer,
|
|
588
|
+
graceMs,
|
|
589
|
+
sockets
|
|
590
|
+
}) {
|
|
591
|
+
return new Promise((resolve) => {
|
|
592
|
+
let graceTimer;
|
|
593
|
+
const settle = () => {
|
|
594
|
+
if (graceTimer !== undefined) {
|
|
595
|
+
clearTimeout(graceTimer);
|
|
596
|
+
graceTimer = undefined;
|
|
597
|
+
}
|
|
598
|
+
resolve();
|
|
599
|
+
};
|
|
600
|
+
httpServer.close(() => settle());
|
|
601
|
+
try {
|
|
602
|
+
httpServer.closeIdleConnections();
|
|
603
|
+
} catch {}
|
|
604
|
+
graceTimer = setTimeout(() => {
|
|
605
|
+
try {
|
|
606
|
+
httpServer.closeAllConnections();
|
|
607
|
+
} catch {}
|
|
608
|
+
for (const socket of sockets)
|
|
609
|
+
socket.destroy();
|
|
610
|
+
}, graceMs);
|
|
611
|
+
graceTimer.unref();
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
function startCliServer(options, holder) {
|
|
615
|
+
if (options.hostname !== undefined && options.hostname.trim() === "") {
|
|
616
|
+
throw new SchmockError4("Invalid hostname. The hostname must be a non-empty host, address or interface.", "INVALID_CONFIG", { option: "hostname", value: options.hostname });
|
|
617
|
+
}
|
|
618
|
+
const hostname = options.hostname ?? "127.0.0.1";
|
|
619
|
+
const port = options.port ?? 3000;
|
|
620
|
+
const cors = options.cors ?? false;
|
|
621
|
+
const graceMs = options.shutdownGraceMs ?? SHUTDOWN_GRACE_MS;
|
|
622
|
+
const admin = options.admin ?? false;
|
|
623
|
+
const adminToken = admin ? options.adminToken : undefined;
|
|
624
|
+
const httpServer = createServer((req, res) => {
|
|
625
|
+
handleCliRequest(req, res, {
|
|
626
|
+
mock: holder.mock,
|
|
627
|
+
admin,
|
|
628
|
+
cors,
|
|
629
|
+
adminToken
|
|
630
|
+
});
|
|
631
|
+
});
|
|
632
|
+
const sockets = new Set;
|
|
633
|
+
httpServer.on("connection", (socket) => {
|
|
634
|
+
sockets.add(socket);
|
|
635
|
+
socket.once("close", () => sockets.delete(socket));
|
|
636
|
+
});
|
|
637
|
+
const cleanups = [];
|
|
638
|
+
let closing;
|
|
639
|
+
const close = () => {
|
|
640
|
+
closing ??= (async () => {
|
|
641
|
+
const settled = closeHttpServer({ httpServer, graceMs, sockets });
|
|
642
|
+
const drain = (async () => {
|
|
643
|
+
for (const cleanup of cleanups.splice(0))
|
|
644
|
+
await cleanup();
|
|
645
|
+
})();
|
|
646
|
+
drain.catch(() => {});
|
|
647
|
+
try {
|
|
648
|
+
await Promise.race([drain, boundedDelay(graceMs)]);
|
|
649
|
+
} finally {
|
|
650
|
+
await settled;
|
|
651
|
+
}
|
|
652
|
+
})();
|
|
653
|
+
return closing;
|
|
654
|
+
};
|
|
655
|
+
return new Promise((resolve, reject) => {
|
|
656
|
+
const onListenError = (error) => reject(error);
|
|
657
|
+
const reportServerError = (error) => {
|
|
658
|
+
process.stderr.write(`Server error: ${error instanceof Error ? error.message : String(error)}
|
|
659
|
+
`);
|
|
660
|
+
};
|
|
661
|
+
httpServer.once("error", onListenError);
|
|
662
|
+
httpServer.listen(port, hostname, () => {
|
|
663
|
+
httpServer.on("error", reportServerError);
|
|
664
|
+
httpServer.off("error", onListenError);
|
|
665
|
+
const addr = httpServer.address();
|
|
666
|
+
const actualPort = addr !== null && typeof addr === "object" ? addr.port : port;
|
|
667
|
+
resolve({
|
|
668
|
+
server: httpServer,
|
|
669
|
+
port: actualPort,
|
|
670
|
+
hostname,
|
|
671
|
+
adminToken,
|
|
672
|
+
close,
|
|
673
|
+
onClose(cleanup) {
|
|
674
|
+
cleanups.push(cleanup);
|
|
675
|
+
}
|
|
676
|
+
});
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
function formatUrlHost(hostname) {
|
|
681
|
+
return hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
|
|
682
|
+
}
|
|
683
|
+
function serverUrl(address) {
|
|
684
|
+
return `http://${formatUrlHost(address.hostname)}:${address.port}`;
|
|
685
|
+
}
|
|
686
|
+
async function createCliServer(options) {
|
|
687
|
+
const resolved = {
|
|
688
|
+
...options,
|
|
689
|
+
adminToken: resolveAdminToken(options)
|
|
690
|
+
};
|
|
691
|
+
const holder = { mock: await createCliMock(resolved) };
|
|
692
|
+
const server = await startCliServer(resolved, holder);
|
|
693
|
+
if (resolved.watch) {
|
|
694
|
+
let watcher;
|
|
695
|
+
try {
|
|
696
|
+
watcher = startWatch({
|
|
697
|
+
options: resolved,
|
|
698
|
+
holder,
|
|
699
|
+
rebuild: () => createCliMock(resolved),
|
|
700
|
+
url: serverUrl(server)
|
|
701
|
+
});
|
|
702
|
+
} catch (error) {
|
|
703
|
+
await server.close();
|
|
704
|
+
throw error;
|
|
705
|
+
}
|
|
706
|
+
server.onClose(() => watcher.close());
|
|
707
|
+
}
|
|
708
|
+
return server;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
// src/cli.ts
|
|
650
712
|
async function run(args) {
|
|
651
713
|
const parsed = parseCliArgs(args);
|
|
652
714
|
const options = { ...parsed, adminToken: resolveAdminToken(parsed) };
|
|
@@ -663,7 +725,7 @@ async function run(args) {
|
|
|
663
725
|
return;
|
|
664
726
|
}
|
|
665
727
|
const cliServer = await createCliServer(options);
|
|
666
|
-
process.stderr.write(`Schmock server running on
|
|
728
|
+
process.stderr.write(`Schmock server running on ${serverUrl(cliServer)}
|
|
667
729
|
`);
|
|
668
730
|
process.stderr.write(`Spec: ${options.spec}
|
|
669
731
|
`);
|
|
@@ -681,24 +743,36 @@ async function run(args) {
|
|
|
681
743
|
` + `WARNING: anyone who can reach this port and the admin token can read recorded requests and reset the mock.
|
|
682
744
|
`);
|
|
683
745
|
}
|
|
746
|
+
} else if (options.adminHistoryLimit !== undefined) {
|
|
747
|
+
process.stderr.write(`WARNING: --admin-history-limit has no effect without --admin; no history is retained.
|
|
748
|
+
`);
|
|
684
749
|
}
|
|
685
750
|
if (options.watch) {
|
|
686
751
|
process.stderr.write(`Watch: enabled
|
|
687
752
|
`);
|
|
688
753
|
}
|
|
754
|
+
const graceMs = options.shutdownGraceMs ?? SHUTDOWN_GRACE_MS;
|
|
689
755
|
return new Promise((resolveRun, rejectRun) => {
|
|
690
756
|
let shuttingDown = false;
|
|
691
757
|
let repeatReported = false;
|
|
758
|
+
let forceExitAfter = Number.POSITIVE_INFINITY;
|
|
692
759
|
const shutdown = () => {
|
|
693
760
|
if (shuttingDown) {
|
|
761
|
+
if (Date.now() >= forceExitAfter) {
|
|
762
|
+
process.stderr.write(`Shutdown did not finish within the grace window; forcing exit.
|
|
763
|
+
`);
|
|
764
|
+
process.exit(1);
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
694
767
|
if (!repeatReported) {
|
|
695
768
|
repeatReported = true;
|
|
696
|
-
process.stderr.write(`Shutdown already in progress; waiting for in-flight requests...
|
|
769
|
+
process.stderr.write(`Shutdown already in progress; waiting for in-flight requests (signal again after ${graceMs} ms to force an exit)...
|
|
697
770
|
`);
|
|
698
771
|
}
|
|
699
772
|
return;
|
|
700
773
|
}
|
|
701
774
|
shuttingDown = true;
|
|
775
|
+
forceExitAfter = Date.now() + graceMs;
|
|
702
776
|
process.stderr.write(`
|
|
703
777
|
Shutting down...
|
|
704
778
|
`);
|