@schmock/cli 2.3.0 → 2.3.1

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