@schmock/core 1.9.1 → 1.9.2

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.
@@ -1 +1 @@
1
- {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAYnB,OAAO,CAAC,YAAY;IAXhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,WAAW,CAA2C;IAC9D,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAAiC;IAEnD,OAAO,CAAC,SAAS,CAAoC;gBAEjC,YAAY,GAAE,OAAO,CAAC,YAAiB;IAa3D,WAAW,CACT,KAAK,EAAE,OAAO,CAAC,QAAQ,EACvB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,MAAM,EAAE,OAAO,CAAC,WAAW,GAC1B,IAAI;IAqFP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAS5E,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO;IAS3D,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAS7D,WAAW,CACT,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,GAAG,SAAS;IAYpC,SAAS,IAAI,OAAO,CAAC,SAAS,EAAE;IAQhC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAMnC,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAC/B,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAUP,GAAG,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAChC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAKP,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAeb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAuCrE,KAAK,IAAI,IAAI;IAUP,MAAM,CACV,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,GAC/B,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;IAqN5B;;;;OAIG;YACW,UAAU;CAezB"}
1
+ {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAuDA;;;;GAIG;AACH,qBAAa,oBAAoB;IAYnB,OAAO,CAAC,YAAY;IAXhC,OAAO,CAAC,MAAM,CAA+B;IAC7C,OAAO,CAAC,YAAY,CAA4C;IAChE,OAAO,CAAC,OAAO,CAAwB;IACvC,OAAO,CAAC,MAAM,CAAc;IAC5B,OAAO,CAAC,cAAc,CAA+B;IACrD,OAAO,CAAC,WAAW,CAA2C;IAC9D,OAAO,CAAC,MAAM,CAAqB;IACnC,OAAO,CAAC,UAAU,CAAiC;IAEnD,OAAO,CAAC,SAAS,CAAoC;gBAEjC,YAAY,GAAE,OAAO,CAAC,YAAiB;IAa3D,WAAW,CACT,KAAK,EAAE,OAAO,CAAC,QAAQ,EACvB,SAAS,EAAE,OAAO,CAAC,SAAS,EAC5B,MAAM,EAAE,OAAO,CAAC,WAAW,GAC1B,IAAI;IAiFP,cAAc,CAAC,GAAG,EAAE,OAAO,CAAC,oBAAoB,GAAG,IAAI;IAIvD,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,MAAM,GAAG,IAAI;IAoBlC,OAAO,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,aAAa,EAAE;IAS5E,MAAM,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,OAAO;IAS3D,SAAS,CAAC,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAAE,IAAI,CAAC,EAAE,MAAM,GAAG,MAAM;IAS7D,WAAW,CACT,MAAM,CAAC,EAAE,OAAO,CAAC,UAAU,EAC3B,IAAI,CAAC,EAAE,MAAM,GACZ,OAAO,CAAC,aAAa,GAAG,SAAS;IAYpC,SAAS,IAAI,OAAO,CAAC,SAAS,EAAE;IAQhC,QAAQ,IAAI,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC;IAMnC,EAAE,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAC/B,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAUP,GAAG,CAAC,CAAC,SAAS,OAAO,CAAC,YAAY,EAChC,KAAK,EAAE,CAAC,EACR,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,CAAC,eAAe,CAAC,CAAC,CAAC,KAAK,IAAI,GACnD,IAAI;IAKP,OAAO,CAAC,IAAI;IAWZ,KAAK,IAAI,IAAI;IAeb,YAAY,IAAI,IAAI;IAKpB,UAAU,IAAI,IAAI;IAWlB,MAAM,CAAC,IAAI,SAAI,EAAE,QAAQ,SAAc,GAAG,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC;IAqDrE,KAAK,IAAI,IAAI;IAUP,MAAM,CACV,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,CAAC,cAAc,GAC/B,OAAO,CAAC,OAAO,CAAC,QAAQ,CAAC;IAoO5B;;;;OAIG;YACW,UAAU;CAezB"}
package/dist/builder.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { createServer } from "node:http";
2
- import { toHttpMethod } from "./constants.js";
2
+ import { normalizePath, toHttpMethod } from "./constants.js";
3
3
  import { errorMessage, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
4
4
  import { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
5
5
  import { parseRouteKey } from "./parser.js";
@@ -119,10 +119,7 @@ export class CallableMockInstance {
119
119
  // Store static routes (no params) in Map for O(1) lookup
120
120
  // Only store the first registration — "first registration wins" semantics
121
121
  if (parsed.params.length === 0) {
122
- const normalizedPath = parsed.path.endsWith("/") && parsed.path !== "/"
123
- ? parsed.path.slice(0, -1)
124
- : parsed.path;
125
- const key = `${parsed.method} ${normalizedPath}`;
122
+ const key = `${parsed.method} ${normalizePath(parsed.path)}`;
126
123
  if (!this.staticRoutes.has(key)) {
127
124
  this.staticRoutes.set(key, compiledRoute);
128
125
  }
@@ -152,26 +149,26 @@ export class CallableMockInstance {
152
149
  }
153
150
  // ===== Request Spy / History API =====
154
151
  history(method, path) {
155
- if (method && path) {
156
- return this.requestHistory.filter((r) => r.method === method && r.path === path);
152
+ if (method || path) {
153
+ return this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
157
154
  }
158
155
  return [...this.requestHistory];
159
156
  }
160
157
  called(method, path) {
161
- if (method && path) {
162
- return this.requestHistory.some((r) => r.method === method && r.path === path);
158
+ if (method || path) {
159
+ return this.requestHistory.some((r) => (!method || r.method === method) && (!path || r.path === path));
163
160
  }
164
161
  return this.requestHistory.length > 0;
165
162
  }
166
163
  callCount(method, path) {
167
- if (method && path) {
168
- return this.requestHistory.filter((r) => r.method === method && r.path === path).length;
164
+ if (method || path) {
165
+ return this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path)).length;
169
166
  }
170
167
  return this.requestHistory.length;
171
168
  }
172
169
  lastRequest(method, path) {
173
- if (method && path) {
174
- const filtered = this.requestHistory.filter((r) => r.method === method && r.path === path);
170
+ if (method || path) {
171
+ const filtered = this.requestHistory.filter((r) => (!method || r.method === method) && (!path || r.path === path));
175
172
  return filtered[filtered.length - 1];
176
173
  }
177
174
  return this.requestHistory[this.requestHistory.length - 1];
@@ -185,7 +182,7 @@ export class CallableMockInstance {
185
182
  }));
186
183
  }
187
184
  getState() {
188
- return this.globalConfig.state || {};
185
+ return { ...(this.globalConfig.state || {}) };
189
186
  }
190
187
  // ===== Lifecycle Events =====
191
188
  on(event, listener) {
@@ -242,14 +239,29 @@ export class CallableMockInstance {
242
239
  throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
243
240
  }
244
241
  const httpServer = createServer((req, res) => {
245
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
246
- const method = toHttpMethod(req.method ?? "GET");
247
- const path = url.pathname;
248
- const headers = parseNodeHeaders(req);
249
- const query = parseNodeQuery(url);
250
- void collectBody(req, headers).then((body) => this.handle(method, path, { headers, body, query }).then((schmockResponse) => {
242
+ const handleRequest = async () => {
243
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
244
+ const method = toHttpMethod(req.method ?? "GET");
245
+ const path = url.pathname;
246
+ const headers = parseNodeHeaders(req);
247
+ const query = parseNodeQuery(url);
248
+ const body = await collectBody(req, headers);
249
+ const schmockResponse = await this.handle(method, path, {
250
+ headers,
251
+ body,
252
+ query,
253
+ });
251
254
  writeSchmockResponse(res, schmockResponse);
252
- }));
255
+ };
256
+ handleRequest().catch((error) => {
257
+ if (!res.headersSent) {
258
+ res.writeHead(500, { "content-type": "application/json" });
259
+ }
260
+ res.end(JSON.stringify({
261
+ error: error instanceof Error ? error.message : "Internal Server Error",
262
+ code: "SERVER_ERROR",
263
+ }));
264
+ });
253
265
  });
254
266
  this.server = httpServer;
255
267
  return new Promise((resolve, reject) => {
@@ -273,18 +285,20 @@ export class CallableMockInstance {
273
285
  this.logger.log("server", "Server stopped");
274
286
  }
275
287
  async handle(method, path, options) {
276
- const requestId = crypto.randomUUID();
277
288
  const handleStart = performance.now();
289
+ const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
290
+ const reqQuery = options?.query || {};
291
+ const reqHeaders = options?.headers || {};
278
292
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
279
- headers: options?.headers,
280
- query: options?.query,
293
+ headers: reqHeaders,
294
+ query: reqQuery,
281
295
  bodyType: options?.body ? typeof options.body : "none",
282
296
  });
283
297
  this.logger.time(`request-${requestId}`);
284
298
  this.emit("request:start", {
285
299
  method,
286
300
  path,
287
- headers: options?.headers || {},
301
+ headers: reqHeaders,
288
302
  });
289
303
  try {
290
304
  // Apply namespace if configured
@@ -307,6 +321,12 @@ export class CallableMockInstance {
307
321
  body: { error: error.message, code: error.code },
308
322
  headers: {},
309
323
  };
324
+ this.emit("request:end", {
325
+ method,
326
+ path,
327
+ status: 404,
328
+ duration: performance.now() - handleStart,
329
+ });
310
330
  this.logger.timeEnd(`request-${requestId}`);
311
331
  return response;
312
332
  }
@@ -348,8 +368,8 @@ export class CallableMockInstance {
348
368
  method,
349
369
  path: requestPath,
350
370
  params,
351
- query: options?.query || {},
352
- headers: options?.headers || {},
371
+ query: reqQuery,
372
+ headers: reqHeaders,
353
373
  body: options?.body,
354
374
  state: this.globalConfig.state || {},
355
375
  };
@@ -366,8 +386,8 @@ export class CallableMockInstance {
366
386
  route: matchedRoute.config,
367
387
  method,
368
388
  params,
369
- query: options?.query || {},
370
- headers: options?.headers || {},
389
+ query: reqQuery,
390
+ headers: reqHeaders,
371
391
  body: options?.body,
372
392
  state: new Map(),
373
393
  routeState: this.globalConfig.state || {},
@@ -391,8 +411,8 @@ export class CallableMockInstance {
391
411
  method,
392
412
  path: requestPath,
393
413
  params,
394
- query: options?.query || {},
395
- headers: options?.headers || {},
414
+ query: reqQuery,
415
+ headers: reqHeaders,
396
416
  body: options?.body,
397
417
  timestamp: Date.now(),
398
418
  response: { status: response.status, body: response.body },
@@ -425,6 +445,12 @@ export class CallableMockInstance {
425
445
  };
426
446
  // Apply delay even for error responses
427
447
  await this.applyDelay();
448
+ this.emit("request:end", {
449
+ method,
450
+ path,
451
+ status: 500,
452
+ duration: performance.now() - handleStart,
453
+ });
428
454
  this.logger.log("error", `[${requestId}] Returning error response 500`);
429
455
  this.logger.timeEnd(`request-${requestId}`);
430
456
  return errorResponse;
@@ -3,6 +3,8 @@ export declare const ROUTE_NOT_FOUND_CODE: "ROUTE_NOT_FOUND";
3
3
  export declare const HTTP_METHODS: readonly HttpMethod[];
4
4
  export declare function isHttpMethod(method: string): method is HttpMethod;
5
5
  export declare function toHttpMethod(method: string): HttpMethod;
6
+ export declare function normalizePath(path: string): string;
7
+ export declare function toRouteKey(method: HttpMethod, path: string): Schmock.RouteKey;
6
8
  /**
7
9
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
8
10
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
@@ -1 +1 @@
1
- {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,eAAO,MAAM,oBAAoB,EAAG,iBAA0B,CAAC;AAE/D,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,IAAI,UAAU,CAEjE;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAMvD;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAQxE"}
1
+ {"version":3,"file":"constants.d.ts","sourceRoot":"","sources":["../src/constants.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,eAAO,MAAM,oBAAoB,EAAG,iBAA0B,CAAC;AAE/D,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,MAAM,IAAI,UAAU,CAEjE;AAED,wBAAgB,YAAY,CAAC,MAAM,EAAE,MAAM,GAAG,UAAU,CAMvD;AAED,wBAAgB,aAAa,CAAC,IAAI,EAAE,MAAM,GAAG,MAAM,CAElD;AAED,wBAAgB,UAAU,CAAC,MAAM,EAAE,UAAU,EAAE,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,QAAQ,CAG7E;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,OAAO,GACb,KAAK,IAAI,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,CAAC,MAAM,EAAE,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,CAQxE"}
package/dist/constants.js CHANGED
@@ -18,6 +18,13 @@ export function toHttpMethod(method) {
18
18
  }
19
19
  return upper;
20
20
  }
21
+ export function normalizePath(path) {
22
+ return path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
23
+ }
24
+ export function toRouteKey(method, path) {
25
+ const key = `${method} ${path}`;
26
+ return key;
27
+ }
21
28
  /**
22
29
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
23
30
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
@@ -12,8 +12,11 @@ export declare function parseNodeQuery(url: URL): Record<string, string>;
12
12
  * Collect and parse the request body from a Node.js IncomingMessage.
13
13
  * Returns parsed JSON if content-type includes "json", otherwise the raw string.
14
14
  * Returns undefined for empty bodies.
15
+ * @param req - Node.js IncomingMessage
16
+ * @param headers - Parsed request headers
17
+ * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
15
18
  */
16
- export declare function collectBody(req: IncomingMessage, headers: Record<string, string>): Promise<unknown>;
19
+ export declare function collectBody(req: IncomingMessage, headers: Record<string, string>, maxBodySize?: number): Promise<unknown>;
17
20
  /**
18
21
  * Write a Schmock Response to a Node.js ServerResponse.
19
22
  * Serializes non-string bodies as JSON and sets content-type when missing.
@@ -1 +1 @@
1
- {"version":3,"file":"http-helpers.d.ts","sourceRoot":"","sources":["../src/http-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQ7E;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAM/D;AAED;;;;GAIG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GAC9B,OAAO,CAAC,OAAO,CAAC,CAsBlB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAC1B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACpC,IAAI,CAuBN"}
1
+ {"version":3,"file":"http-helpers.d.ts","sourceRoot":"","sources":["../src/http-helpers.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,cAAc,EAAE,MAAM,WAAW,CAAC;AAEjE;;;GAGG;AACH,wBAAgB,gBAAgB,CAAC,GAAG,EAAE,eAAe,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAQ7E;AAED;;GAEG;AACH,wBAAgB,cAAc,CAAC,GAAG,EAAE,GAAG,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAM/D;AAKD;;;;;;;GAOG;AACH,wBAAgB,WAAW,CACzB,GAAG,EAAE,eAAe,EACpB,OAAO,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,EAC/B,WAAW,SAAwB,GAClC,OAAO,CAAC,OAAO,CAAC,CAqClB;AAED;;;GAGG;AACH,wBAAgB,oBAAoB,CAClC,GAAG,EAAE,cAAc,EACnB,QAAQ,EAAE,OAAO,CAAC,QAAQ,EAC1B,YAAY,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,GACpC,IAAI,CAuBN"}
@@ -21,15 +21,30 @@ export function parseNodeQuery(url) {
21
21
  });
22
22
  return query;
23
23
  }
24
+ /** Default body size limit: 10 MB */
25
+ const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
24
26
  /**
25
27
  * Collect and parse the request body from a Node.js IncomingMessage.
26
28
  * Returns parsed JSON if content-type includes "json", otherwise the raw string.
27
29
  * Returns undefined for empty bodies.
30
+ * @param req - Node.js IncomingMessage
31
+ * @param headers - Parsed request headers
32
+ * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
28
33
  */
29
- export function collectBody(req, headers) {
30
- return new Promise((resolve) => {
34
+ export function collectBody(req, headers, maxBodySize = DEFAULT_MAX_BODY_SIZE) {
35
+ return new Promise((resolve, reject) => {
31
36
  const chunks = [];
32
- req.on("data", (chunk) => chunks.push(chunk));
37
+ let totalSize = 0;
38
+ req.on("error", reject);
39
+ req.on("data", (chunk) => {
40
+ totalSize += chunk.length;
41
+ if (totalSize > maxBodySize) {
42
+ req.destroy();
43
+ reject(Object.assign(new Error("Request body too large"), { status: 413 }));
44
+ return;
45
+ }
46
+ chunks.push(chunk);
47
+ });
33
48
  req.on("end", () => {
34
49
  const raw = Buffer.concat(chunks).toString();
35
50
  if (!raw) {
package/dist/index.d.ts CHANGED
@@ -22,7 +22,7 @@
22
22
  * @returns A callable mock instance
23
23
  */
24
24
  export declare function schmock(config?: Schmock.GlobalConfig): Schmock.CallableMockInstance;
25
- export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, } from "./constants.js";
25
+ export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, toRouteKey, } from "./constants.js";
26
26
  export { PluginError, ResourceLimitError, ResponseGenerationError, RouteDefinitionError, RouteNotFoundError, RouteParseError, SchemaGenerationError, SchemaValidationError, SchmockError, } from "./errors.js";
27
27
  export { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
28
28
  export type { CallableMockInstance, Generator, GeneratorFunction, GlobalConfig, HttpMethod, Plugin, PluginContext, PluginResult, RequestContext, RequestOptions, RequestRecord, Response, ResponseBody, ResponseResult, RouteConfig, RouteInfo, RouteKey, ServerInfo, StaticData, } from "./types.js";
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,CAmD9B;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,YAAY,GACb,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACV,UAAU,GACX,MAAM,YAAY,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAEA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAgB,OAAO,CACrB,MAAM,CAAC,EAAE,OAAO,CAAC,YAAY,GAC5B,OAAO,CAAC,oBAAoB,CAmD9B;AAGD,OAAO,EACL,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,oBAAoB,EACpB,YAAY,EACZ,UAAU,GACX,MAAM,gBAAgB,CAAC;AAExB,OAAO,EACL,WAAW,EACX,kBAAkB,EAClB,uBAAuB,EACvB,oBAAoB,EACpB,kBAAkB,EAClB,eAAe,EACf,qBAAqB,EACrB,qBAAqB,EACrB,YAAY,GACb,MAAM,aAAa,CAAC;AAErB,OAAO,EACL,WAAW,EACX,gBAAgB,EAChB,cAAc,EACd,oBAAoB,GACrB,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EACV,oBAAoB,EACpB,SAAS,EACT,iBAAiB,EACjB,YAAY,EACZ,UAAU,EACV,MAAM,EACN,aAAa,EACb,YAAY,EACZ,cAAc,EACd,cAAc,EACd,aAAa,EACb,QAAQ,EACR,YAAY,EACZ,cAAc,EACd,WAAW,EACX,SAAS,EACT,QAAQ,EACR,UAAU,EACV,UAAU,GACX,MAAM,YAAY,CAAC"}
package/dist/index.js CHANGED
@@ -59,7 +59,7 @@ export function schmock(config) {
59
59
  return callableInstance;
60
60
  }
61
61
  // Re-export constants and utilities
62
- export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, } from "./constants.js";
62
+ export { HTTP_METHODS, isHttpMethod, isStatusTuple, ROUTE_NOT_FOUND_CODE, toHttpMethod, toRouteKey, } from "./constants.js";
63
63
  // Re-export errors
64
64
  export { PluginError, ResourceLimitError, ResponseGenerationError, RouteDefinitionError, RouteNotFoundError, RouteParseError, SchemaGenerationError, SchemaValidationError, SchmockError, } from "./errors.js";
65
65
  // Re-export HTTP server helpers
@@ -1 +1 @@
1
- {"version":3,"file":"route-matcher.d.ts","sourceRoot":"","sources":["../src/route-matcher.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC;IAC7B,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC;CAC7B;AAED,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,OAAO,CAAC,SAAS,GACrB,GAAG,IAAI,OAAO,CAAC,iBAAiB,CAElC;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,qBAAqB,CAAC,EAChD,MAAM,EAAE,qBAAqB,EAAE,GAC9B,qBAAqB,GAAG,SAAS,CAqBnC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,qBAAqB,EAC5B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUxB"}
1
+ {"version":3,"file":"route-matcher.d.ts","sourceRoot":"","sources":["../src/route-matcher.ts"],"names":[],"mappings":"AAEA;;GAEG;AACH,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;IACjB,MAAM,EAAE,OAAO,CAAC,UAAU,CAAC;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,EAAE,OAAO,CAAC,SAAS,CAAC;IAC7B,MAAM,EAAE,OAAO,CAAC,WAAW,CAAC;CAC7B;AAED,wBAAgB,mBAAmB,CACjC,GAAG,EAAE,OAAO,CAAC,SAAS,GACrB,GAAG,IAAI,OAAO,CAAC,iBAAiB,CAElC;AAED;;;;GAIG;AACH,wBAAgB,SAAS,CACvB,MAAM,EAAE,OAAO,CAAC,UAAU,EAC1B,IAAI,EAAE,MAAM,EACZ,YAAY,EAAE,GAAG,CAAC,MAAM,EAAE,qBAAqB,CAAC,EAChD,MAAM,EAAE,qBAAqB,EAAE,GAC9B,qBAAqB,GAAG,SAAS,CAmBnC;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,KAAK,EAAE,qBAAqB,EAC5B,IAAI,EAAE,MAAM,GACX,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAUxB"}
@@ -1,3 +1,4 @@
1
+ import { normalizePath } from "./constants.js";
1
2
  export function isGeneratorFunction(gen) {
2
3
  return typeof gen === "function";
3
4
  }
@@ -8,8 +9,7 @@ export function isGeneratorFunction(gen) {
8
9
  */
9
10
  export function findRoute(method, path, staticRoutes, routes) {
10
11
  // O(1) lookup for static routes
11
- const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
12
- const staticMatch = staticRoutes.get(`${method} ${normalizedPath}`);
12
+ const staticMatch = staticRoutes.get(`${method} ${normalizePath(path)}`);
13
13
  if (staticMatch) {
14
14
  return staticMatch;
15
15
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/core",
3
3
  "description": "Core functionality for Schmock",
4
- "version": "1.9.1",
4
+ "version": "1.9.2",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -33,7 +33,8 @@
33
33
  "license": "MIT",
34
34
  "devDependencies": {
35
35
  "@amiceli/vitest-cucumber": "^6.2.0",
36
- "@types/node": "^25.2.3",
36
+ "@types/json-schema": "^7.0.15",
37
+ "@types/node": "^25.3.0",
37
38
  "@vitest/ui": "^4.0.18",
38
39
  "vitest": "^4.0.18"
39
40
  }
package/src/builder.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { Server } from "node:http";
2
2
  import { createServer } from "node:http";
3
- import { toHttpMethod } from "./constants.js";
3
+ import { normalizePath, toHttpMethod } from "./constants.js";
4
4
  import {
5
5
  errorMessage,
6
6
  RouteDefinitionError,
@@ -153,11 +153,7 @@ export class CallableMockInstance {
153
153
  // Store static routes (no params) in Map for O(1) lookup
154
154
  // Only store the first registration — "first registration wins" semantics
155
155
  if (parsed.params.length === 0) {
156
- const normalizedPath =
157
- parsed.path.endsWith("/") && parsed.path !== "/"
158
- ? parsed.path.slice(0, -1)
159
- : parsed.path;
160
- const key = `${parsed.method} ${normalizedPath}`;
156
+ const key = `${parsed.method} ${normalizePath(parsed.path)}`;
161
157
  if (!this.staticRoutes.has(key)) {
162
158
  this.staticRoutes.set(key, compiledRoute);
163
159
  }
@@ -197,27 +193,27 @@ export class CallableMockInstance {
197
193
  // ===== Request Spy / History API =====
198
194
 
199
195
  history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
200
- if (method && path) {
196
+ if (method || path) {
201
197
  return this.requestHistory.filter(
202
- (r) => r.method === method && r.path === path,
198
+ (r) => (!method || r.method === method) && (!path || r.path === path),
203
199
  );
204
200
  }
205
201
  return [...this.requestHistory];
206
202
  }
207
203
 
208
204
  called(method?: Schmock.HttpMethod, path?: string): boolean {
209
- if (method && path) {
205
+ if (method || path) {
210
206
  return this.requestHistory.some(
211
- (r) => r.method === method && r.path === path,
207
+ (r) => (!method || r.method === method) && (!path || r.path === path),
212
208
  );
213
209
  }
214
210
  return this.requestHistory.length > 0;
215
211
  }
216
212
 
217
213
  callCount(method?: Schmock.HttpMethod, path?: string): number {
218
- if (method && path) {
214
+ if (method || path) {
219
215
  return this.requestHistory.filter(
220
- (r) => r.method === method && r.path === path,
216
+ (r) => (!method || r.method === method) && (!path || r.path === path),
221
217
  ).length;
222
218
  }
223
219
  return this.requestHistory.length;
@@ -227,9 +223,9 @@ export class CallableMockInstance {
227
223
  method?: Schmock.HttpMethod,
228
224
  path?: string,
229
225
  ): Schmock.RequestRecord | undefined {
230
- if (method && path) {
226
+ if (method || path) {
231
227
  const filtered = this.requestHistory.filter(
232
- (r) => r.method === method && r.path === path,
228
+ (r) => (!method || r.method === method) && (!path || r.path === path),
233
229
  );
234
230
  return filtered[filtered.length - 1];
235
231
  }
@@ -247,7 +243,7 @@ export class CallableMockInstance {
247
243
  }
248
244
 
249
245
  getState(): Record<string, unknown> {
250
- return this.globalConfig.state || {};
246
+ return { ...(this.globalConfig.state || {}) };
251
247
  }
252
248
 
253
249
  // ===== Lifecycle Events =====
@@ -324,19 +320,33 @@ export class CallableMockInstance {
324
320
  }
325
321
 
326
322
  const httpServer = createServer((req, res) => {
327
- const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
328
- const method = toHttpMethod(req.method ?? "GET");
329
- const path = url.pathname;
330
- const headers = parseNodeHeaders(req);
331
- const query = parseNodeQuery(url);
332
-
333
- void collectBody(req, headers).then((body) =>
334
- this.handle(method, path, { headers, body, query }).then(
335
- (schmockResponse) => {
336
- writeSchmockResponse(res, schmockResponse);
337
- },
338
- ),
339
- );
323
+ const handleRequest = async () => {
324
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
325
+ const method = toHttpMethod(req.method ?? "GET");
326
+ const path = url.pathname;
327
+ const headers = parseNodeHeaders(req);
328
+ const query = parseNodeQuery(url);
329
+ const body = await collectBody(req, headers);
330
+ const schmockResponse = await this.handle(method, path, {
331
+ headers,
332
+ body,
333
+ query,
334
+ });
335
+ writeSchmockResponse(res, schmockResponse);
336
+ };
337
+
338
+ handleRequest().catch((error) => {
339
+ if (!res.headersSent) {
340
+ res.writeHead(500, { "content-type": "application/json" });
341
+ }
342
+ res.end(
343
+ JSON.stringify({
344
+ error:
345
+ error instanceof Error ? error.message : "Internal Server Error",
346
+ code: "SERVER_ERROR",
347
+ }),
348
+ );
349
+ });
340
350
  });
341
351
 
342
352
  this.server = httpServer;
@@ -369,11 +379,13 @@ export class CallableMockInstance {
369
379
  path: string,
370
380
  options?: Schmock.RequestOptions,
371
381
  ): Promise<Schmock.Response> {
372
- const requestId = crypto.randomUUID();
373
382
  const handleStart = performance.now();
383
+ const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
384
+ const reqQuery = options?.query || {};
385
+ const reqHeaders = options?.headers || {};
374
386
  this.logger.log("request", `[${requestId}] ${method} ${path}`, {
375
- headers: options?.headers,
376
- query: options?.query,
387
+ headers: reqHeaders,
388
+ query: reqQuery,
377
389
  bodyType: options?.body ? typeof options.body : "none",
378
390
  });
379
391
  this.logger.time(`request-${requestId}`);
@@ -381,7 +393,7 @@ export class CallableMockInstance {
381
393
  this.emit("request:start", {
382
394
  method,
383
395
  path,
384
- headers: options?.headers || {},
396
+ headers: reqHeaders,
385
397
  });
386
398
 
387
399
  try {
@@ -414,6 +426,12 @@ export class CallableMockInstance {
414
426
  body: { error: error.message, code: error.code },
415
427
  headers: {},
416
428
  };
429
+ this.emit("request:end", {
430
+ method,
431
+ path,
432
+ status: 404,
433
+ duration: performance.now() - handleStart,
434
+ });
417
435
  this.logger.timeEnd(`request-${requestId}`);
418
436
  return response;
419
437
  }
@@ -473,8 +491,8 @@ export class CallableMockInstance {
473
491
  method,
474
492
  path: requestPath,
475
493
  params,
476
- query: options?.query || {},
477
- headers: options?.headers || {},
494
+ query: reqQuery,
495
+ headers: reqHeaders,
478
496
  body: options?.body,
479
497
  state: this.globalConfig.state || {},
480
498
  };
@@ -492,8 +510,8 @@ export class CallableMockInstance {
492
510
  route: matchedRoute.config,
493
511
  method,
494
512
  params,
495
- query: options?.query || {},
496
- headers: options?.headers || {},
513
+ query: reqQuery,
514
+ headers: reqHeaders,
497
515
  body: options?.body,
498
516
  state: new Map(),
499
517
  routeState: this.globalConfig.state || {},
@@ -528,8 +546,8 @@ export class CallableMockInstance {
528
546
  method,
529
547
  path: requestPath,
530
548
  params,
531
- query: options?.query || {},
532
- headers: options?.headers || {},
549
+ query: reqQuery,
550
+ headers: reqHeaders,
533
551
  body: options?.body,
534
552
  timestamp: Date.now(),
535
553
  response: { status: response.status, body: response.body },
@@ -575,6 +593,13 @@ export class CallableMockInstance {
575
593
  // Apply delay even for error responses
576
594
  await this.applyDelay();
577
595
 
596
+ this.emit("request:end", {
597
+ method,
598
+ path,
599
+ status: 500,
600
+ duration: performance.now() - handleStart,
601
+ });
602
+
578
603
  this.logger.log("error", `[${requestId}] Returning error response 500`);
579
604
  this.logger.timeEnd(`request-${requestId}`);
580
605
  return errorResponse;
package/src/constants.ts CHANGED
@@ -24,6 +24,15 @@ export function toHttpMethod(method: string): HttpMethod {
24
24
  return upper;
25
25
  }
26
26
 
27
+ export function normalizePath(path: string): string {
28
+ return path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
29
+ }
30
+
31
+ export function toRouteKey(method: HttpMethod, path: string): Schmock.RouteKey {
32
+ const key: `${HttpMethod} ${string}` = `${method} ${path}`;
33
+ return key;
34
+ }
35
+
27
36
  /**
28
37
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
29
38
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
@@ -25,18 +25,40 @@ export function parseNodeQuery(url: URL): Record<string, string> {
25
25
  return query;
26
26
  }
27
27
 
28
+ /** Default body size limit: 10 MB */
29
+ const DEFAULT_MAX_BODY_SIZE = 10 * 1024 * 1024;
30
+
28
31
  /**
29
32
  * Collect and parse the request body from a Node.js IncomingMessage.
30
33
  * Returns parsed JSON if content-type includes "json", otherwise the raw string.
31
34
  * Returns undefined for empty bodies.
35
+ * @param req - Node.js IncomingMessage
36
+ * @param headers - Parsed request headers
37
+ * @param maxBodySize - Maximum body size in bytes (default: 10 MB)
32
38
  */
33
39
  export function collectBody(
34
40
  req: IncomingMessage,
35
41
  headers: Record<string, string>,
42
+ maxBodySize = DEFAULT_MAX_BODY_SIZE,
36
43
  ): Promise<unknown> {
37
- return new Promise((resolve) => {
44
+ return new Promise((resolve, reject) => {
38
45
  const chunks: Buffer[] = [];
39
- req.on("data", (chunk: Buffer) => chunks.push(chunk));
46
+ let totalSize = 0;
47
+
48
+ req.on("error", reject);
49
+
50
+ req.on("data", (chunk: Buffer) => {
51
+ totalSize += chunk.length;
52
+ if (totalSize > maxBodySize) {
53
+ req.destroy();
54
+ reject(
55
+ Object.assign(new Error("Request body too large"), { status: 413 }),
56
+ );
57
+ return;
58
+ }
59
+ chunks.push(chunk);
60
+ });
61
+
40
62
  req.on("end", () => {
41
63
  const raw = Buffer.concat(chunks).toString();
42
64
  if (!raw) {
package/src/index.ts CHANGED
@@ -85,6 +85,7 @@ export {
85
85
  isStatusTuple,
86
86
  ROUTE_NOT_FOUND_CODE,
87
87
  toHttpMethod,
88
+ toRouteKey,
88
89
  } from "./constants.js";
89
90
  // Re-export errors
90
91
  export {
@@ -1,3 +1,5 @@
1
+ import { normalizePath } from "./constants.js";
2
+
1
3
  /**
2
4
  * Compiled callable route with pattern matching
3
5
  */
@@ -28,9 +30,7 @@ export function findRoute(
28
30
  routes: CompiledCallableRoute[],
29
31
  ): CompiledCallableRoute | undefined {
30
32
  // O(1) lookup for static routes
31
- const normalizedPath =
32
- path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
33
- const staticMatch = staticRoutes.get(`${method} ${normalizedPath}`);
33
+ const staticMatch = staticRoutes.get(`${method} ${normalizePath(path)}`);
34
34
  if (staticMatch) {
35
35
  return staticMatch;
36
36
  }