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