@schmock/cli 2.4.0 → 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/bin.js CHANGED
@@ -1,126 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/cli.ts
3
+ // src/admin.ts
4
4
  import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
5
- import { readFileSync, realpathSync, statSync, watch } from "node:fs";
6
- import { createServer } from "node:http";
7
- import {
8
- basename,
9
- dirname,
10
- isAbsolute,
11
- relative,
12
- resolve as resolvePath,
13
- sep
14
- } from "node:path";
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 adminResponseHeaders(extra = {}) {
61
+ function adminResponse(status, body, extraHeaders = {}) {
158
62
  return {
159
- "content-type": "application/json",
160
- "cache-control": "no-store",
161
- vary: "origin",
162
- ...extra
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 denyAdminRequest(req, res, adminToken) {
73
+ function refuseAdminRequest(req, adminToken) {
166
74
  if (req.headers.origin !== undefined) {
167
- res.writeHead(403, adminResponseHeaders());
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
- res.writeHead(401, adminResponseHeaders({
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 false;
87
+ return;
186
88
  }
187
- function isRecord(value) {
89
+ function isObjectLike(value) {
188
90
  return typeof value === "object" && value !== null;
189
91
  }
190
- function redactHeaders(headers) {
191
- if (!isRecord(headers))
92
+ function redactEntries(entries, isSensitive) {
93
+ if (!isObjectLike(entries))
192
94
  return {};
193
95
  const redacted = {};
194
- for (const [name, value] of Object.entries(headers)) {
195
- redacted[name] = SENSITIVE_HEADERS.has(name.toLowerCase()) ? REDACTED : value;
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
- headers: redactHeaders(record.headers)
104
+ query: redactEntries(record.query, isSensitiveQueryKey),
105
+ headers: redactEntries(record.headers, isSensitiveHeader)
203
106
  }));
204
107
  }
205
- function handleAdminRequest(method, path, mock, res, headers) {
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
- res.writeHead(200, headers);
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
- res.writeHead(200, headers);
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
- res.writeHead(204, headers);
221
- res.end();
222
- return;
123
+ return adminResponse(204);
223
124
  }
224
125
  if (method === "GET" && route === "history") {
225
- res.writeHead(200, headers);
226
- res.end(JSON.stringify(redactHistory(mock.history())));
227
- return;
126
+ return adminResponse(200, JSON.stringify(redactHistory(mock.history())));
228
127
  }
229
- res.writeHead(404, headers);
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 parseRequestUrl(req) {
233
- const host = req.headers.host;
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
- async function handleCliRequest(req, res, mock, options) {
259
- const abortController = new AbortController;
260
- const abortRequest = () => abortController.abort();
261
- req.once("aborted", abortRequest);
262
- res.once("close", abortRequest);
263
- let admission;
264
- let requestMethod = req.method?.toUpperCase() === "HEAD" ? "HEAD" : "GET";
265
- let adminRequest = false;
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
- return options.adminToken ?? randomUUID();
442
- }
443
- async function createCliServer(options) {
444
- const resolved = {
445
- ...options,
446
- adminToken: resolveAdminToken(options)
447
- };
448
- const holder = { mock: await createCliMock(resolved) };
449
- const server = await startCliServer(resolved, holder);
450
- if (resolved.watch) {
451
- let watcher;
452
- try {
453
- watcher = startWatch(resolved.spec, resolved, holder, server);
454
- } catch (error) {
455
- await server.close();
456
- throw error;
457
- }
458
- server.onClose(() => watcher.close());
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 server;
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 = Number(value);
166
+ const port = parseDecimal(value);
471
167
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
472
- throw new Error(`Invalid port "${value}". Port must be an integer between 0 and 65535.`);
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 = value.trim() === "" ? Number.NaN : Number(value);
173
+ const limit = parseDecimal(value);
478
174
  if (!Number.isInteger(limit) || limit < 0) {
479
- throw new Error(`Invalid --admin-history-limit "${value}". It must be a non-negative integer.`);
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.trim() === "" ? Number.NaN : Number(value);
180
+ const seed = parseDecimal(value, { signed: true });
485
181
  if (!Number.isInteger(seed)) {
486
- throw new Error(`Invalid --seed-random "${value}". It must be a finite integer.`);
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 new Error("Invalid --hostname. The hostname must be a non-empty host, address or interface.");
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 (value === "" || /\s/.test(value)) {
502
- throw new Error("Invalid --admin-token. The token must be non-empty and contain no whitespace.");
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 new Error(`Unexpected extra arguments: ${positionals.slice(1).join(", ")}. Pass exactly one spec path.`);
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) : undefined,
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, options) {
400
+ async function reloadMock({ holder, rebuild }) {
588
401
  const previous = holder.mock;
589
- holder.mock = await createCliMock(options);
402
+ holder.mock = await rebuild();
590
403
  try {
591
404
  previous.reset();
592
405
  } catch {}
593
406
  }
594
- function startWatch(specPath, options, holder, address) {
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
- const resolvedSpec = resolvePath(specPath);
599
- const specDirectory = dirname(resolvedSpec);
600
- const specName = basename(resolvedSpec);
601
- const isSpecEvent = (filename) => {
602
- if (filename == null)
603
- return true;
604
- const name = basename(filename.toString());
605
- return name === specName || name === basename(specDirectory);
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 watcher = watch(specDirectory, (_event, filename) => {
476
+ const rearm = () => {
608
477
  if (closed)
609
478
  return;
610
- if (!isSpecEvent(filename))
611
- return;
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(holder, options);
507
+ await reloadMock(input);
623
508
  if (closed)
624
509
  return;
625
- process.stderr.write(`Schmock server reloaded on http://${address.hostname}:${address.port}
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
- watcher.on("error", (error) => {
637
- process.stderr.write(`Spec watch error: ${error instanceof Error ? error.message : String(error)}
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
- watcher.close();
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 http://${cliServer.hostname}:${cliServer.port}
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
  `);