@schmock/cli 2.4.1 → 2.6.0

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