@schmock/cli 2.2.2 → 2.3.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/cli.d.ts CHANGED
@@ -25,7 +25,8 @@ export interface WatchHandle {
25
25
  close(): void;
26
26
  }
27
27
  /**
28
- * Reload a CLI server: close the old one, create a new one on the same port.
28
+ * Prepare a replacement before closing the live server, then bind it to the
29
+ * same port. Invalid spec changes therefore leave the current server online.
29
30
  */
30
31
  export declare function reloadServer(current: CliServer, options: CliOptions): Promise<CliServer>;
31
32
  /**
package/dist/cli.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,WAAW,CAAC;AAaxC,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,IAAI,IAAI,CAAC;CACf;AA0ED,wBAAsB,eAAe,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAuF7E;AAYD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,GAAG;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAqC3E;AAqBD,MAAM,WAAW,WAAW;IAC1B,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;GAEG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,SAAS,EAClB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,SAAS,CAAC,CAQpB;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,UAAU,EACnB,gBAAgB,EAAE,MAAM,SAAS,EACjC,QAAQ,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,GACpC,WAAW,CA2Bb;AAED,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiDvD"}
1
+ {"version":3,"file":"cli.d.ts","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,MAAM,EAAkB,MAAM,WAAW,CAAC;AAcxD,MAAM,WAAW,UAAU;IACzB,IAAI,EAAE,MAAM,CAAC;IACb,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,OAAO,CAAC;IACf,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,MAAM,CAAC,EAAE,OAAO,CAAC;IACjB,KAAK,CAAC,EAAE,OAAO,CAAC;IAChB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,SAAS;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,IAAI,EAAE,MAAM,CAAC;IACb,QAAQ,EAAE,MAAM,CAAC;IACjB,KAAK,IAAI,IAAI,CAAC;CACf;AA6QD,wBAAsB,eAAe,CAAC,OAAO,EAAE,UAAU,GAAG,OAAO,CAAC,SAAS,CAAC,CAG7E;AAYD,wBAAgB,YAAY,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,UAAU,GAAG;IAAE,IAAI,EAAE,OAAO,CAAA;CAAE,CAqC3E;AAqBD,MAAM,WAAW,WAAW;IAC1B,KAAK,IAAI,IAAI,CAAC;CACf;AAED;;;GAGG;AACH,wBAAsB,YAAY,CAChC,OAAO,EAAE,SAAS,EAClB,OAAO,EAAE,UAAU,GAClB,OAAO,CAAC,SAAS,CAAC,CAWpB;AAED;;GAEG;AACH,wBAAgB,UAAU,CACxB,QAAQ,EAAE,MAAM,EAChB,OAAO,EAAE,UAAU,EACnB,gBAAgB,EAAE,MAAM,SAAS,EACjC,QAAQ,EAAE,CAAC,MAAM,EAAE,SAAS,KAAK,IAAI,GACpC,WAAW,CAqCb;AAED,wBAAsB,GAAG,CAAC,IAAI,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC,IAAI,CAAC,CAiDvD"}
package/dist/cli.js CHANGED
@@ -2,13 +2,27 @@
2
2
  import { readFileSync, watch } from "node:fs";
3
3
  import { createServer } from "node:http";
4
4
  import { parseArgs } from "node:util";
5
- import { collectBody, parseNodeHeaders, parseNodeQuery, schmock, toHttpMethod, writeSchmockResponse, } from "@schmock/core";
5
+ import { collectBody, HTTP_METHODS, isHttpMethod, parseNodeHeaders, parseNodeQuery, schmock, writeSchmockResponse, } from "@schmock/core";
6
6
  import { openapi } from "@schmock/openapi";
7
+ const MAX_REQUEST_BODY_SIZE = 10 * 1024 * 1024;
8
+ const ALLOWED_METHODS = HTTP_METHODS.join(", ");
7
9
  const CORS_HEADERS = {
8
10
  "access-control-allow-origin": "*",
9
- "access-control-allow-methods": "GET, POST, PUT, PATCH, DELETE, OPTIONS",
11
+ "access-control-allow-methods": ALLOWED_METHODS,
10
12
  "access-control-allow-headers": "Content-Type, Authorization",
11
13
  };
14
+ class CliHttpError extends Error {
15
+ status;
16
+ code;
17
+ headers;
18
+ constructor(status, code, message, headers = {}) {
19
+ super(message);
20
+ this.status = status;
21
+ this.code = code;
22
+ this.headers = headers;
23
+ this.name = "CliHttpError";
24
+ }
25
+ }
12
26
  function isSeedSource(value) {
13
27
  return (Array.isArray(value) ||
14
28
  typeof value === "string" ||
@@ -55,7 +69,95 @@ function handleAdminRequest(method, path, mock, res, headers) {
55
69
  res.writeHead(404, headers);
56
70
  res.end(JSON.stringify({ error: "Unknown admin endpoint", code: "NOT_FOUND" }));
57
71
  }
58
- export async function createCliServer(options) {
72
+ function parseRequestUrl(req) {
73
+ const host = req.headers.host;
74
+ if (!host) {
75
+ throw new CliHttpError(400, "BAD_REQUEST", "Missing Host header");
76
+ }
77
+ try {
78
+ return new URL(req.url ?? "/", `http://${host}`);
79
+ }
80
+ catch {
81
+ throw new CliHttpError(400, "BAD_REQUEST", "Malformed request target");
82
+ }
83
+ }
84
+ function parseRequestMethod(req) {
85
+ const method = (req.method ?? "GET").toUpperCase();
86
+ if (!isHttpMethod(method)) {
87
+ throw new CliHttpError(405, "METHOD_NOT_ALLOWED", `Unsupported HTTP method: ${method}`, { allow: ALLOWED_METHODS });
88
+ }
89
+ return method;
90
+ }
91
+ function assertBodySize(headers) {
92
+ const contentLength = headers["content-length"];
93
+ if (contentLength === undefined)
94
+ return;
95
+ const declaredSize = Number(contentLength);
96
+ if (Number.isFinite(declaredSize) && declaredSize > MAX_REQUEST_BODY_SIZE) {
97
+ throw new CliHttpError(413, "PAYLOAD_TOO_LARGE", "Request body too large");
98
+ }
99
+ }
100
+ function toCliHttpError(error) {
101
+ if (error instanceof CliHttpError)
102
+ return error;
103
+ if (error !== null &&
104
+ typeof error === "object" &&
105
+ "status" in error &&
106
+ error.status === 413) {
107
+ return new CliHttpError(413, "PAYLOAD_TOO_LARGE", error instanceof Error ? error.message : "Request body too large");
108
+ }
109
+ return new CliHttpError(500, "SERVER_ERROR", error instanceof Error ? error.message : "Internal Server Error");
110
+ }
111
+ async function handleCliRequest(req, res, mock, options) {
112
+ try {
113
+ const url = parseRequestUrl(req);
114
+ const method = parseRequestMethod(req);
115
+ const path = url.pathname;
116
+ if (options.cors && method === "OPTIONS") {
117
+ res.writeHead(204, CORS_HEADERS);
118
+ res.end();
119
+ return;
120
+ }
121
+ if (options.admin && path.startsWith("/schmock-admin/")) {
122
+ const adminHeaders = {
123
+ "content-type": "application/json",
124
+ ...(options.cors ? CORS_HEADERS : {}),
125
+ };
126
+ handleAdminRequest(method, path, mock, res, adminHeaders);
127
+ return;
128
+ }
129
+ const headers = parseNodeHeaders(req);
130
+ assertBodySize(headers);
131
+ const query = parseNodeQuery(url);
132
+ const body = await collectBody(req, headers, MAX_REQUEST_BODY_SIZE);
133
+ const schmockResponse = await mock.handle(method, path, {
134
+ headers,
135
+ body,
136
+ query,
137
+ });
138
+ const extraHeaders = options.cors ? CORS_HEADERS : undefined;
139
+ writeSchmockResponse(res, schmockResponse, extraHeaders);
140
+ }
141
+ catch (error) {
142
+ const cliError = toCliHttpError(error);
143
+ try {
144
+ if (!res.headersSent) {
145
+ res.writeHead(cliError.status, {
146
+ "content-type": "application/json",
147
+ ...(options.cors ? CORS_HEADERS : {}),
148
+ ...cliError.headers,
149
+ });
150
+ }
151
+ if (!res.writableEnded) {
152
+ res.end(JSON.stringify({ error: cliError.message, code: cliError.code }));
153
+ }
154
+ }
155
+ catch {
156
+ res.destroy();
157
+ }
158
+ }
159
+ }
160
+ async function createCliMock(options) {
59
161
  const mock = schmock({ debug: options.debug, state: {} });
60
162
  const openapiOptions = {
61
163
  spec: options.spec,
@@ -67,47 +169,15 @@ export async function createCliServer(options) {
67
169
  }
68
170
  const plugin = await openapi(openapiOptions);
69
171
  mock.pipe(plugin);
172
+ return mock;
173
+ }
174
+ function listenWithMock(options, mock) {
70
175
  const hostname = options.hostname ?? "127.0.0.1";
71
176
  const port = options.port ?? 3000;
72
177
  const cors = options.cors ?? false;
73
178
  const admin = options.admin ?? false;
74
179
  const httpServer = createServer((req, res) => {
75
- // Handle CORS preflight
76
- if (cors && req.method === "OPTIONS") {
77
- res.writeHead(204, CORS_HEADERS);
78
- res.end();
79
- return;
80
- }
81
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
82
- const path = url.pathname;
83
- // Admin API intercept
84
- if (admin && path.startsWith("/schmock-admin/")) {
85
- const adminHeaders = {
86
- "content-type": "application/json",
87
- ...(cors ? CORS_HEADERS : {}),
88
- };
89
- handleAdminRequest(req.method ?? "GET", path, mock, res, adminHeaders);
90
- return;
91
- }
92
- const method = toHttpMethod(req.method ?? "GET");
93
- const headers = parseNodeHeaders(req);
94
- const query = parseNodeQuery(url);
95
- void collectBody(req, headers)
96
- .then((body) => mock
97
- .handle(method, path, { headers, body, query })
98
- .then((schmockResponse) => {
99
- const extra = cors ? CORS_HEADERS : undefined;
100
- writeSchmockResponse(res, schmockResponse, extra);
101
- }))
102
- .catch((error) => {
103
- if (!res.headersSent) {
104
- res.writeHead(500, { "content-type": "application/json" });
105
- }
106
- res.end(JSON.stringify({
107
- error: error instanceof Error ? error.message : "Internal Server Error",
108
- code: "SERVER_ERROR",
109
- }));
110
- });
180
+ void handleCliRequest(req, res, mock, { admin, cors });
111
181
  });
112
182
  return new Promise((resolve, reject) => {
113
183
  httpServer.on("error", reject);
@@ -125,6 +195,10 @@ export async function createCliServer(options) {
125
195
  });
126
196
  });
127
197
  }
198
+ export async function createCliServer(options) {
199
+ const mock = await createCliMock(options);
200
+ return listenWithMock(options, mock);
201
+ }
128
202
  function validatePort(value) {
129
203
  const port = Number(value);
130
204
  if (!Number.isInteger(port) || port < 0 || port > 65535) {
@@ -186,39 +260,53 @@ Options:
186
260
  `;
187
261
  const WATCH_DEBOUNCE_MS = 500;
188
262
  /**
189
- * Reload a CLI server: close the old one, create a new one on the same port.
263
+ * Prepare a replacement before closing the live server, then bind it to the
264
+ * same port. Invalid spec changes therefore leave the current server online.
190
265
  */
191
266
  export async function reloadServer(current, options) {
192
267
  const port = current.port;
268
+ const nextOptions = { ...options, port };
269
+ const replacementMock = await createCliMock(nextOptions);
193
270
  // Close existing connections and wait for the server to fully close
194
271
  current.server.closeAllConnections();
195
272
  await new Promise((resolve) => {
196
273
  current.server.close(() => resolve());
197
274
  });
198
- return createCliServer({ ...options, port });
275
+ return listenWithMock(nextOptions, replacementMock);
199
276
  }
200
277
  /**
201
278
  * Watch a spec file and hot-reload the server on changes.
202
279
  */
203
280
  export function startWatch(specPath, options, getCurrentServer, onReload) {
204
281
  let debounceTimer;
282
+ let closed = false;
283
+ let reloadQueue = Promise.resolve();
205
284
  const watcher = watch(specPath, () => {
206
285
  if (debounceTimer)
207
286
  clearTimeout(debounceTimer);
208
- debounceTimer = setTimeout(async () => {
209
- process.stderr.write("\nSpec changed, reloading...\n");
210
- try {
211
- const newServer = await reloadServer(getCurrentServer(), options);
212
- onReload(newServer);
213
- process.stderr.write(`Schmock server reloaded on http://${newServer.hostname}:${newServer.port}\n`);
214
- }
215
- catch (err) {
216
- process.stderr.write(`Reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
217
- }
287
+ debounceTimer = setTimeout(() => {
288
+ reloadQueue = reloadQueue.then(async () => {
289
+ if (closed)
290
+ return;
291
+ process.stderr.write("\nSpec changed, reloading...\n");
292
+ try {
293
+ const newServer = await reloadServer(getCurrentServer(), options);
294
+ if (closed) {
295
+ newServer.close();
296
+ return;
297
+ }
298
+ onReload(newServer);
299
+ process.stderr.write(`Schmock server reloaded on http://${newServer.hostname}:${newServer.port}\n`);
300
+ }
301
+ catch (err) {
302
+ process.stderr.write(`Reload failed: ${err instanceof Error ? err.message : String(err)}\n`);
303
+ }
304
+ });
218
305
  }, WATCH_DEBOUNCE_MS);
219
306
  });
220
307
  return {
221
308
  close() {
309
+ closed = true;
222
310
  watcher.close();
223
311
  if (debounceTimer)
224
312
  clearTimeout(debounceTimer);