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