@schmock/core 1.8.0 → 1.9.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/builder.d.ts CHANGED
@@ -24,8 +24,8 @@ export declare class CallableMockInstance {
24
24
  lastRequest(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord | undefined;
25
25
  getRoutes(): Schmock.RouteInfo[];
26
26
  getState(): Record<string, unknown>;
27
- on(event: string, listener: (data: unknown) => void): this;
28
- off(event: string, listener: (data: unknown) => void): this;
27
+ on<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): this;
28
+ off<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): this;
29
29
  private emit;
30
30
  reset(): void;
31
31
  resetHistory(): void;
@@ -39,45 +39,5 @@ export declare class CallableMockInstance {
39
39
  * @private
40
40
  */
41
41
  private applyDelay;
42
- /**
43
- * Parse and normalize response result into Response object
44
- * Handles tuple format [status, body, headers], direct values, and response objects
45
- * @param result - Raw result from generator or plugin
46
- * @param routeConfig - Route configuration for content-type defaults
47
- * @returns Normalized Response object with status, body, and headers
48
- * @private
49
- */
50
- private parseResponse;
51
- /**
52
- * Run all registered plugins in sequence
53
- * First plugin to set response becomes generator, subsequent plugins transform
54
- * Handles plugin errors via onError hooks
55
- * @param context - Plugin context with request details
56
- * @param initialResponse - Initial response from route generator
57
- * @param _routeConfig - Route config (unused but kept for signature)
58
- * @param _requestId - Request ID (unused but kept for signature)
59
- * @returns Updated context and final response after all plugins
60
- * @private
61
- */
62
- private runPluginPipeline;
63
- /**
64
- * Find a route that matches the given method and path
65
- * Uses two-pass matching: static routes first, then parameterized routes
66
- * Matches routes in registration order (first registered wins)
67
- * @param method - HTTP method to match
68
- * @param path - Request path to match
69
- * @returns Matched compiled route or undefined if no match
70
- * @private
71
- */
72
- private findRoute;
73
- /**
74
- * Extract parameter values from path based on route pattern
75
- * Maps capture groups from regex match to parameter names
76
- * @param route - Compiled route with pattern and param names
77
- * @param path - Request path to extract values from
78
- * @returns Object mapping parameter names to extracted values
79
- * @private
80
- */
81
- private extractParams;
82
42
  }
83
43
  //# sourceMappingURL=builder.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"builder.d.ts","sourceRoot":"","sources":["../src/builder.ts"],"names":[],"mappings":"AAkFA;;;;GAIG;AACH,qBAAa,oBAAoB;IAWnB,OAAO,CAAC,YAAY;IAVhC,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;IACnD,OAAO,CAAC,SAAS,CAAmD;gBAEhD,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,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI;IAU1D,GAAG,CAAC,KAAK,EAAE,MAAM,EAAE,QAAQ,EAAE,CAAC,IAAI,EAAE,OAAO,KAAK,IAAI,GAAG,IAAI;IAK3D,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;IAgN5B;;;;OAIG;YACW,UAAU;IAgBxB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;IAwDrB;;;;;;;;;;OAUG;YACW,iBAAiB;IAqG/B;;;;;;;;OAQG;IACH,OAAO,CAAC,SAAS;IA0BjB;;;;;;;OAOG;IACH,OAAO,CAAC,aAAa;CActB"}
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"}
package/dist/builder.js CHANGED
@@ -1,11 +1,11 @@
1
1
  import { createServer } from "node:http";
2
- import { isStatusTuple, toHttpMethod } from "./constants.js";
3
- import { PluginError, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
2
+ import { toHttpMethod } from "./constants.js";
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";
6
- function errorMessage(error) {
7
- return error instanceof Error ? error.message : "Unknown error";
8
- }
6
+ import { runPluginPipeline } from "./plugin-pipeline.js";
7
+ import { parseResponse } from "./response-parser.js";
8
+ import { extractParams, findRoute, isGeneratorFunction, } from "./route-matcher.js";
9
9
  /**
10
10
  * Debug logger that respects debug mode configuration
11
11
  */
@@ -37,15 +37,6 @@ class DebugLogger {
37
37
  console.timeEnd(`[SCHMOCK] ${label}`);
38
38
  }
39
39
  }
40
- function isGeneratorFunction(gen) {
41
- return typeof gen === "function";
42
- }
43
- function isResponseObject(value) {
44
- return (typeof value === "object" &&
45
- value !== null &&
46
- "status" in value &&
47
- "body" in value);
48
- }
49
40
  /**
50
41
  * Callable mock instance that implements the new API.
51
42
  *
@@ -61,6 +52,7 @@ export class CallableMockInstance {
61
52
  callableRef;
62
53
  server;
63
54
  serverInfo;
55
+ // biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
64
56
  listeners = new Map();
65
57
  constructor(globalConfig = {}) {
66
58
  this.globalConfig = globalConfig;
@@ -323,7 +315,7 @@ export class CallableMockInstance {
323
315
  requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
324
316
  }
325
317
  // Find matching route
326
- const matchedRoute = this.findRoute(method, requestPath);
318
+ const matchedRoute = findRoute(method, requestPath, this.staticRoutes, this.routes);
327
319
  if (!matchedRoute) {
328
320
  this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
329
321
  this.emit("request:notfound", { method, path: requestPath });
@@ -344,7 +336,7 @@ export class CallableMockInstance {
344
336
  }
345
337
  this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
346
338
  // Extract parameters from the matched route
347
- const params = this.extractParams(matchedRoute, requestPath);
339
+ const params = extractParams(matchedRoute, requestPath);
348
340
  this.emit("request:match", {
349
341
  method,
350
342
  path: requestPath,
@@ -382,7 +374,7 @@ export class CallableMockInstance {
382
374
  };
383
375
  // Run plugin pipeline to transform the response
384
376
  try {
385
- const pipelineResult = await this.runPluginPipeline(pluginContext, result, matchedRoute.config, requestId);
377
+ const pipelineResult = await runPluginPipeline(this.plugins, pluginContext, result, this.logger);
386
378
  pluginContext = pipelineResult.context;
387
379
  result = pipelineResult.response;
388
380
  }
@@ -391,7 +383,7 @@ export class CallableMockInstance {
391
383
  throw error;
392
384
  }
393
385
  // Parse and prepare response
394
- const response = this.parseResponse(result, matchedRoute.config);
386
+ const response = parseResponse(result, matchedRoute.config);
395
387
  // Apply delay (route-level overrides global)
396
388
  await this.applyDelay(matchedRoute.config.delay);
397
389
  // Record request in history
@@ -454,173 +446,4 @@ export class CallableMockInstance {
454
446
  : effectiveDelay;
455
447
  await new Promise((resolve) => setTimeout(resolve, ms));
456
448
  }
457
- /**
458
- * Parse and normalize response result into Response object
459
- * Handles tuple format [status, body, headers], direct values, and response objects
460
- * @param result - Raw result from generator or plugin
461
- * @param routeConfig - Route configuration for content-type defaults
462
- * @returns Normalized Response object with status, body, and headers
463
- * @private
464
- */
465
- parseResponse(result, routeConfig) {
466
- let status = 200;
467
- let body = result;
468
- let headers = {};
469
- let tupleFormat = false;
470
- // Handle already-formed response objects (from plugin error recovery)
471
- if (isResponseObject(result)) {
472
- return {
473
- status: result.status,
474
- body: result.body,
475
- headers: result.headers || {},
476
- };
477
- }
478
- // Handle tuple response format [status, body, headers?]
479
- if (isStatusTuple(result)) {
480
- [status, body, headers = {}] = result;
481
- tupleFormat = true;
482
- }
483
- // Handle null/undefined responses with 204 No Content
484
- // But don't auto-convert if tuple format was used (status was explicitly provided)
485
- if (body === null || body === undefined) {
486
- if (!tupleFormat) {
487
- status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
488
- }
489
- body = undefined; // Ensure body is undefined for null responses
490
- }
491
- // Add content-type header from route config if it exists and headers don't already have it
492
- // But only if this isn't a tuple response (where headers are explicitly controlled)
493
- if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
494
- headers["content-type"] = routeConfig.contentType;
495
- // Handle special conversion cases when contentType is explicitly set
496
- if (routeConfig.contentType === "text/plain" && body !== undefined) {
497
- if (typeof body === "object" && !Buffer.isBuffer(body)) {
498
- body = JSON.stringify(body);
499
- }
500
- else if (typeof body !== "string") {
501
- body = String(body);
502
- }
503
- }
504
- }
505
- return {
506
- status,
507
- body,
508
- headers,
509
- };
510
- }
511
- /**
512
- * Run all registered plugins in sequence
513
- * First plugin to set response becomes generator, subsequent plugins transform
514
- * Handles plugin errors via onError hooks
515
- * @param context - Plugin context with request details
516
- * @param initialResponse - Initial response from route generator
517
- * @param _routeConfig - Route config (unused but kept for signature)
518
- * @param _requestId - Request ID (unused but kept for signature)
519
- * @returns Updated context and final response after all plugins
520
- * @private
521
- */
522
- async runPluginPipeline(context, initialResponse, _routeConfig, _requestId) {
523
- let currentContext = context;
524
- let response = initialResponse;
525
- this.logger.log("pipeline", `Running plugin pipeline for ${this.plugins.length} plugins`);
526
- for (const plugin of this.plugins) {
527
- this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
528
- try {
529
- const result = await plugin.process(currentContext, response);
530
- if (!result || !result.context) {
531
- throw new Error(`Plugin ${plugin.name} didn't return valid result`);
532
- }
533
- currentContext = result.context;
534
- // First plugin to set response becomes the generator
535
- if (result.response !== undefined &&
536
- (response === undefined || response === null)) {
537
- this.logger.log("pipeline", `Plugin ${plugin.name} generated response`);
538
- response = result.response;
539
- }
540
- else if (result.response !== undefined && response !== undefined) {
541
- this.logger.log("pipeline", `Plugin ${plugin.name} transformed response`);
542
- response = result.response;
543
- }
544
- }
545
- catch (error) {
546
- this.logger.log("pipeline", `Plugin ${plugin.name} failed: ${errorMessage(error)}`);
547
- // Try error handling if plugin has onError hook
548
- if (plugin.onError) {
549
- try {
550
- const pluginError = error instanceof Error ? error : new Error(errorMessage(error));
551
- const errorResult = await plugin.onError(pluginError, currentContext);
552
- if (errorResult) {
553
- this.logger.log("pipeline", `Plugin ${plugin.name} handled error`);
554
- // Error return → transform the thrown error
555
- if (errorResult instanceof Error) {
556
- throw new PluginError(plugin.name, errorResult);
557
- }
558
- // ResponseResult return → recover, stop pipeline
559
- if (typeof errorResult === "object" &&
560
- errorResult !== null &&
561
- "status" in errorResult) {
562
- response = errorResult;
563
- break;
564
- }
565
- }
566
- // void/falsy return → propagate original error below
567
- }
568
- catch (hookError) {
569
- // If the hook itself threw (including our PluginError above), re-throw it
570
- if (hookError instanceof PluginError) {
571
- throw hookError;
572
- }
573
- this.logger.log("pipeline", `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`);
574
- }
575
- }
576
- const cause = error instanceof Error ? error : new Error(errorMessage(error));
577
- throw new PluginError(plugin.name, cause);
578
- }
579
- }
580
- return { context: currentContext, response };
581
- }
582
- /**
583
- * Find a route that matches the given method and path
584
- * Uses two-pass matching: static routes first, then parameterized routes
585
- * Matches routes in registration order (first registered wins)
586
- * @param method - HTTP method to match
587
- * @param path - Request path to match
588
- * @returns Matched compiled route or undefined if no match
589
- * @private
590
- */
591
- findRoute(method, path) {
592
- // O(1) lookup for static routes
593
- const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
594
- const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
595
- if (staticMatch) {
596
- return staticMatch;
597
- }
598
- // Fall through to parameterized route scan
599
- for (const route of this.routes) {
600
- if (route.method === method &&
601
- route.params.length > 0 &&
602
- route.pattern.test(path)) {
603
- return route;
604
- }
605
- }
606
- return undefined;
607
- }
608
- /**
609
- * Extract parameter values from path based on route pattern
610
- * Maps capture groups from regex match to parameter names
611
- * @param route - Compiled route with pattern and param names
612
- * @param path - Request path to extract values from
613
- * @returns Object mapping parameter names to extracted values
614
- * @private
615
- */
616
- extractParams(route, path) {
617
- const match = path.match(route.pattern);
618
- if (!match)
619
- return {};
620
- const params = {};
621
- route.params.forEach((param, index) => {
622
- params[param] = match[index + 1];
623
- });
624
- return params;
625
- }
626
449
  }
package/dist/errors.d.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export declare function errorMessage(error: unknown): string;
1
2
  /**
2
3
  * Base error class for all Schmock errors
3
4
  */
@@ -1 +1 @@
1
- {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA;;GAEG;AACH,qBAAa,YAAa,SAAQ,KAAK;aAGnB,IAAI,EAAE,MAAM;aACZ,OAAO,CAAC,EAAE,OAAO;gBAFjC,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,YAAA;CAQpC;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAOzC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,YAAY;gBACnC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,YAAY;gBAC3C,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAQxC;AAED;;GAEG;AACH,qBAAa,WAAY,SAAQ,YAAY;gBAC/B,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAO7C;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;gBACxC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;CAQnE;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,OAAO;CAQ1D;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAQ7D"}
1
+ {"version":3,"file":"errors.d.ts","sourceRoot":"","sources":["../src/errors.ts"],"names":[],"mappings":"AAAA,wBAAgB,YAAY,CAAC,KAAK,EAAE,OAAO,GAAG,MAAM,CAEnD;AAED;;GAEG;AACH,qBAAa,YAAa,SAAQ,KAAK;aAGnB,IAAI,EAAE,MAAM;aACZ,OAAO,CAAC,EAAE,OAAO;gBAFjC,OAAO,EAAE,MAAM,EACC,IAAI,EAAE,MAAM,EACZ,OAAO,CAAC,EAAE,OAAO,YAAA;CAQpC;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,MAAM,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM;CAOzC;AAED;;GAEG;AACH,qBAAa,eAAgB,SAAQ,YAAY;gBACnC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,uBAAwB,SAAQ,YAAY;gBAC3C,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAQxC;AAED;;GAEG;AACH,qBAAa,WAAY,SAAQ,YAAY;gBAC/B,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK;CAO7C;AAED;;GAEG;AACH,qBAAa,oBAAqB,SAAQ,YAAY;gBACxC,QAAQ,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM;CAQ7C;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,UAAU,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,UAAU,CAAC,EAAE,MAAM;CAQnE;AAED;;GAEG;AACH,qBAAa,qBAAsB,SAAQ,YAAY;gBACzC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,CAAC,EAAE,OAAO;CAQ1D;AAED;;GAEG;AACH,qBAAa,kBAAmB,SAAQ,YAAY;gBACtC,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM;CAQ7D"}
package/dist/errors.js CHANGED
@@ -1,3 +1,6 @@
1
+ export function errorMessage(error) {
2
+ return error instanceof Error ? error.message : "Unknown error";
3
+ }
1
4
  /**
2
5
  * Base error class for all Schmock errors
3
6
  */
@@ -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,CA6C9B;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,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"}
package/dist/index.js CHANGED
@@ -42,14 +42,14 @@ export function schmock(config) {
42
42
  reset: instance.reset.bind(instance),
43
43
  resetHistory: instance.resetHistory.bind(instance),
44
44
  resetState: instance.resetState.bind(instance),
45
- on: ((event, listener) => {
45
+ on(event, listener) {
46
46
  instance.on(event, listener);
47
47
  return callableInstance;
48
- }),
49
- off: ((event, listener) => {
48
+ },
49
+ off(event, listener) {
50
50
  instance.off(event, listener);
51
51
  return callableInstance;
52
- }),
52
+ },
53
53
  getRoutes: instance.getRoutes.bind(instance),
54
54
  getState: instance.getState.bind(instance),
55
55
  listen: instance.listen.bind(instance),
@@ -0,0 +1,15 @@
1
+ /** Structural typing — DebugLogger satisfies this without an import */
2
+ interface PipelineLogger {
3
+ log(category: string, message: string, data?: unknown): void;
4
+ }
5
+ /**
6
+ * Run all registered plugins in sequence
7
+ * First plugin to set response becomes generator, subsequent plugins transform
8
+ * Handles plugin errors via onError hooks
9
+ */
10
+ export declare function runPluginPipeline(plugins: Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger): Promise<{
11
+ context: Schmock.PluginContext;
12
+ response?: unknown;
13
+ }>;
14
+ export {};
15
+ //# sourceMappingURL=plugin-pipeline.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"plugin-pipeline.d.ts","sourceRoot":"","sources":["../src/plugin-pipeline.ts"],"names":[],"mappings":"AAEA,uEAAuE;AACvE,UAAU,cAAc;IACtB,GAAG,CAAC,QAAQ,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,GAAG,IAAI,CAAC;CAC9D;AAED;;;;GAIG;AACH,wBAAsB,iBAAiB,CACrC,OAAO,EAAE,OAAO,CAAC,MAAM,EAAE,EACzB,OAAO,EAAE,OAAO,CAAC,aAAa,EAC9B,eAAe,EAAE,OAAO,EACxB,MAAM,EAAE,cAAc,GACrB,OAAO,CAAC;IAAE,OAAO,EAAE,OAAO,CAAC,aAAa,CAAC;IAAC,QAAQ,CAAC,EAAE,OAAO,CAAA;CAAE,CAAC,CAkFjE"}
@@ -0,0 +1,66 @@
1
+ import { errorMessage, PluginError } from "./errors.js";
2
+ /**
3
+ * Run all registered plugins in sequence
4
+ * First plugin to set response becomes generator, subsequent plugins transform
5
+ * Handles plugin errors via onError hooks
6
+ */
7
+ export async function runPluginPipeline(plugins, context, initialResponse, logger) {
8
+ let currentContext = context;
9
+ let response = initialResponse;
10
+ logger.log("pipeline", `Running plugin pipeline for ${plugins.length} plugins`);
11
+ for (const plugin of plugins) {
12
+ logger.log("pipeline", `Processing plugin: ${plugin.name}`);
13
+ try {
14
+ const result = await plugin.process(currentContext, response);
15
+ if (!result || !result.context) {
16
+ throw new Error(`Plugin ${plugin.name} didn't return valid result`);
17
+ }
18
+ currentContext = result.context;
19
+ // First plugin to set response becomes the generator
20
+ if (result.response !== undefined &&
21
+ (response === undefined || response === null)) {
22
+ logger.log("pipeline", `Plugin ${plugin.name} generated response`);
23
+ response = result.response;
24
+ }
25
+ else if (result.response !== undefined && response !== undefined) {
26
+ logger.log("pipeline", `Plugin ${plugin.name} transformed response`);
27
+ response = result.response;
28
+ }
29
+ }
30
+ catch (error) {
31
+ logger.log("pipeline", `Plugin ${plugin.name} failed: ${errorMessage(error)}`);
32
+ // Try error handling if plugin has onError hook
33
+ if (plugin.onError) {
34
+ try {
35
+ const pluginError = error instanceof Error ? error : new Error(errorMessage(error));
36
+ const errorResult = await plugin.onError(pluginError, currentContext);
37
+ if (errorResult) {
38
+ logger.log("pipeline", `Plugin ${plugin.name} handled error`);
39
+ // Error return → transform the thrown error
40
+ if (errorResult instanceof Error) {
41
+ throw new PluginError(plugin.name, errorResult);
42
+ }
43
+ // ResponseResult return → recover, stop pipeline
44
+ if (typeof errorResult === "object" &&
45
+ errorResult !== null &&
46
+ "status" in errorResult) {
47
+ response = errorResult;
48
+ break;
49
+ }
50
+ }
51
+ // void/falsy return → propagate original error below
52
+ }
53
+ catch (hookError) {
54
+ // If the hook itself threw (including our PluginError above), re-throw it
55
+ if (hookError instanceof PluginError) {
56
+ throw hookError;
57
+ }
58
+ logger.log("pipeline", `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`);
59
+ }
60
+ }
61
+ const cause = error instanceof Error ? error : new Error(errorMessage(error));
62
+ throw new PluginError(plugin.name, cause);
63
+ }
64
+ }
65
+ return { context: currentContext, response };
66
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Parse and normalize response result into Response object
3
+ * Handles tuple format [status, body, headers], direct values, and response objects
4
+ */
5
+ export declare function parseResponse(result: unknown, routeConfig: Schmock.RouteConfig): Schmock.Response;
6
+ //# sourceMappingURL=response-parser.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"response-parser.d.ts","sourceRoot":"","sources":["../src/response-parser.ts"],"names":[],"mappings":"AAeA;;;GAGG;AACH,wBAAgB,aAAa,CAC3B,MAAM,EAAE,OAAO,EACf,WAAW,EAAE,OAAO,CAAC,WAAW,GAC/B,OAAO,CAAC,QAAQ,CAmDlB"}
@@ -0,0 +1,57 @@
1
+ import { isStatusTuple } from "./constants.js";
2
+ function isResponseObject(value) {
3
+ return (typeof value === "object" &&
4
+ value !== null &&
5
+ "status" in value &&
6
+ "body" in value);
7
+ }
8
+ /**
9
+ * Parse and normalize response result into Response object
10
+ * Handles tuple format [status, body, headers], direct values, and response objects
11
+ */
12
+ export function parseResponse(result, routeConfig) {
13
+ let status = 200;
14
+ let body = result;
15
+ let headers = {};
16
+ let tupleFormat = false;
17
+ // Handle already-formed response objects (from plugin error recovery)
18
+ if (isResponseObject(result)) {
19
+ return {
20
+ status: result.status,
21
+ body: result.body,
22
+ headers: result.headers || {},
23
+ };
24
+ }
25
+ // Handle tuple response format [status, body, headers?]
26
+ if (isStatusTuple(result)) {
27
+ [status, body, headers = {}] = result;
28
+ tupleFormat = true;
29
+ }
30
+ // Handle null/undefined responses with 204 No Content
31
+ // But don't auto-convert if tuple format was used (status was explicitly provided)
32
+ if (body === null || body === undefined) {
33
+ if (!tupleFormat) {
34
+ status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
35
+ }
36
+ body = undefined; // Ensure body is undefined for null responses
37
+ }
38
+ // Add content-type header from route config if it exists and headers don't already have it
39
+ // But only if this isn't a tuple response (where headers are explicitly controlled)
40
+ if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
41
+ headers["content-type"] = routeConfig.contentType;
42
+ // Handle special conversion cases when contentType is explicitly set
43
+ if (routeConfig.contentType === "text/plain" && body !== undefined) {
44
+ if (typeof body === "object" && !Buffer.isBuffer(body)) {
45
+ body = JSON.stringify(body);
46
+ }
47
+ else if (typeof body !== "string") {
48
+ body = String(body);
49
+ }
50
+ }
51
+ }
52
+ return {
53
+ status,
54
+ body,
55
+ headers,
56
+ };
57
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Compiled callable route with pattern matching
3
+ */
4
+ export interface CompiledCallableRoute {
5
+ pattern: RegExp;
6
+ params: string[];
7
+ method: Schmock.HttpMethod;
8
+ path: string;
9
+ generator: Schmock.Generator;
10
+ config: Schmock.RouteConfig;
11
+ }
12
+ export declare function isGeneratorFunction(gen: Schmock.Generator): gen is Schmock.GeneratorFunction;
13
+ /**
14
+ * Find a route that matches the given method and path
15
+ * Uses two-pass matching: static routes first, then parameterized routes
16
+ * Matches routes in registration order (first registered wins)
17
+ */
18
+ export declare function findRoute(method: Schmock.HttpMethod, path: string, staticRoutes: Map<string, CompiledCallableRoute>, routes: CompiledCallableRoute[]): CompiledCallableRoute | undefined;
19
+ /**
20
+ * Extract parameter values from path based on route pattern
21
+ * Maps capture groups from regex match to parameter names
22
+ */
23
+ export declare function extractParams(route: CompiledCallableRoute, path: string): Record<string, string>;
24
+ //# sourceMappingURL=route-matcher.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,39 @@
1
+ export function isGeneratorFunction(gen) {
2
+ return typeof gen === "function";
3
+ }
4
+ /**
5
+ * Find a route that matches the given method and path
6
+ * Uses two-pass matching: static routes first, then parameterized routes
7
+ * Matches routes in registration order (first registered wins)
8
+ */
9
+ export function findRoute(method, path, staticRoutes, routes) {
10
+ // O(1) lookup for static routes
11
+ const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
12
+ const staticMatch = staticRoutes.get(`${method} ${normalizedPath}`);
13
+ if (staticMatch) {
14
+ return staticMatch;
15
+ }
16
+ // Fall through to parameterized route scan
17
+ for (const route of routes) {
18
+ if (route.method === method &&
19
+ route.params.length > 0 &&
20
+ route.pattern.test(path)) {
21
+ return route;
22
+ }
23
+ }
24
+ return undefined;
25
+ }
26
+ /**
27
+ * Extract parameter values from path based on route pattern
28
+ * Maps capture groups from regex match to parameter names
29
+ */
30
+ export function extractParams(route, path) {
31
+ const match = path.match(route.pattern);
32
+ if (!match)
33
+ return {};
34
+ const params = {};
35
+ route.params.forEach((param, index) => {
36
+ params[param] = match[index + 1];
37
+ });
38
+ return params;
39
+ }
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.8.0",
4
+ "version": "1.9.1",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
@@ -33,7 +33,7 @@
33
33
  "license": "MIT",
34
34
  "devDependencies": {
35
35
  "@amiceli/vitest-cucumber": "^6.2.0",
36
- "@types/node": "^25.2.1",
36
+ "@types/node": "^25.2.3",
37
37
  "@vitest/ui": "^4.0.18",
38
38
  "vitest": "^4.0.18"
39
39
  }
package/src/builder.ts CHANGED
@@ -1,8 +1,8 @@
1
1
  import type { Server } from "node:http";
2
2
  import { createServer } from "node:http";
3
- import { isStatusTuple, toHttpMethod } from "./constants.js";
3
+ import { toHttpMethod } from "./constants.js";
4
4
  import {
5
- PluginError,
5
+ errorMessage,
6
6
  RouteDefinitionError,
7
7
  RouteNotFoundError,
8
8
  SchmockError,
@@ -14,10 +14,14 @@ import {
14
14
  writeSchmockResponse,
15
15
  } from "./http-helpers.js";
16
16
  import { parseRouteKey } from "./parser.js";
17
-
18
- function errorMessage(error: unknown): string {
19
- return error instanceof Error ? error.message : "Unknown error";
20
- }
17
+ import { runPluginPipeline } from "./plugin-pipeline.js";
18
+ import { parseResponse } from "./response-parser.js";
19
+ import type { CompiledCallableRoute } from "./route-matcher.js";
20
+ import {
21
+ extractParams,
22
+ findRoute,
23
+ isGeneratorFunction,
24
+ } from "./route-matcher.js";
21
25
 
22
26
  /**
23
27
  * Debug logger that respects debug mode configuration
@@ -49,37 +53,6 @@ class DebugLogger {
49
53
  }
50
54
  }
51
55
 
52
- /**
53
- * Compiled callable route with pattern matching
54
- */
55
- interface CompiledCallableRoute {
56
- pattern: RegExp;
57
- params: string[];
58
- method: Schmock.HttpMethod;
59
- path: string;
60
- generator: Schmock.Generator;
61
- config: Schmock.RouteConfig;
62
- }
63
-
64
- function isGeneratorFunction(
65
- gen: Schmock.Generator,
66
- ): gen is Schmock.GeneratorFunction {
67
- return typeof gen === "function";
68
- }
69
-
70
- function isResponseObject(value: unknown): value is {
71
- status: number;
72
- body: unknown;
73
- headers?: Record<string, string>;
74
- } {
75
- return (
76
- typeof value === "object" &&
77
- value !== null &&
78
- "status" in value &&
79
- "body" in value
80
- );
81
- }
82
-
83
56
  /**
84
57
  * Callable mock instance that implements the new API.
85
58
  *
@@ -94,7 +67,8 @@ export class CallableMockInstance {
94
67
  private callableRef: Schmock.CallableMockInstance | undefined;
95
68
  private server: Server | undefined;
96
69
  private serverInfo: Schmock.ServerInfo | undefined;
97
- private listeners = new Map<string, Set<(data: unknown) => void>>();
70
+ // biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
71
+ private listeners = new Map<string, Set<Function>>();
98
72
 
99
73
  constructor(private globalConfig: Schmock.GlobalConfig = {}) {
100
74
  this.logger = new DebugLogger(globalConfig.debug || false);
@@ -278,7 +252,10 @@ export class CallableMockInstance {
278
252
 
279
253
  // ===== Lifecycle Events =====
280
254
 
281
- on(event: string, listener: (data: unknown) => void): this {
255
+ on<E extends Schmock.SchmockEvent>(
256
+ event: E,
257
+ listener: (data: Schmock.SchmockEventMap[E]) => void,
258
+ ): this {
282
259
  let set = this.listeners.get(event);
283
260
  if (!set) {
284
261
  set = new Set();
@@ -288,7 +265,10 @@ export class CallableMockInstance {
288
265
  return this;
289
266
  }
290
267
 
291
- off(event: string, listener: (data: unknown) => void): this {
268
+ off<E extends Schmock.SchmockEvent>(
269
+ event: E,
270
+ listener: (data: Schmock.SchmockEventMap[E]) => void,
271
+ ): this {
292
272
  this.listeners.get(event)?.delete(listener);
293
273
  return this;
294
274
  }
@@ -444,7 +424,12 @@ export class CallableMockInstance {
444
424
  }
445
425
 
446
426
  // Find matching route
447
- const matchedRoute = this.findRoute(method, requestPath);
427
+ const matchedRoute = findRoute(
428
+ method,
429
+ requestPath,
430
+ this.staticRoutes,
431
+ this.routes,
432
+ );
448
433
 
449
434
  if (!matchedRoute) {
450
435
  this.logger.log(
@@ -474,7 +459,7 @@ export class CallableMockInstance {
474
459
  );
475
460
 
476
461
  // Extract parameters from the matched route
477
- const params = this.extractParams(matchedRoute, requestPath);
462
+ const params = extractParams(matchedRoute, requestPath);
478
463
 
479
464
  this.emit("request:match", {
480
465
  method,
@@ -516,11 +501,11 @@ export class CallableMockInstance {
516
501
 
517
502
  // Run plugin pipeline to transform the response
518
503
  try {
519
- const pipelineResult = await this.runPluginPipeline(
504
+ const pipelineResult = await runPluginPipeline(
505
+ this.plugins,
520
506
  pluginContext,
521
507
  result,
522
- matchedRoute.config,
523
- requestId,
508
+ this.logger,
524
509
  );
525
510
  pluginContext = pipelineResult.context;
526
511
  result = pipelineResult.response;
@@ -533,7 +518,7 @@ export class CallableMockInstance {
533
518
  }
534
519
 
535
520
  // Parse and prepare response
536
- const response = this.parseResponse(result, matchedRoute.config);
521
+ const response = parseResponse(result, matchedRoute.config);
537
522
 
538
523
  // Apply delay (route-level overrides global)
539
524
  await this.applyDelay(matchedRoute.config.delay);
@@ -616,238 +601,4 @@ export class CallableMockInstance {
616
601
 
617
602
  await new Promise((resolve) => setTimeout(resolve, ms));
618
603
  }
619
-
620
- /**
621
- * Parse and normalize response result into Response object
622
- * Handles tuple format [status, body, headers], direct values, and response objects
623
- * @param result - Raw result from generator or plugin
624
- * @param routeConfig - Route configuration for content-type defaults
625
- * @returns Normalized Response object with status, body, and headers
626
- * @private
627
- */
628
- private parseResponse(
629
- result: unknown,
630
- routeConfig: Schmock.RouteConfig,
631
- ): Schmock.Response {
632
- let status = 200;
633
- let body: unknown = result;
634
- let headers: Record<string, string> = {};
635
-
636
- let tupleFormat = false;
637
-
638
- // Handle already-formed response objects (from plugin error recovery)
639
- if (isResponseObject(result)) {
640
- return {
641
- status: result.status,
642
- body: result.body,
643
- headers: result.headers || {},
644
- };
645
- }
646
-
647
- // Handle tuple response format [status, body, headers?]
648
- if (isStatusTuple(result)) {
649
- [status, body, headers = {}] = result;
650
- tupleFormat = true;
651
- }
652
-
653
- // Handle null/undefined responses with 204 No Content
654
- // But don't auto-convert if tuple format was used (status was explicitly provided)
655
- if (body === null || body === undefined) {
656
- if (!tupleFormat) {
657
- status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
658
- }
659
- body = undefined; // Ensure body is undefined for null responses
660
- }
661
-
662
- // Add content-type header from route config if it exists and headers don't already have it
663
- // But only if this isn't a tuple response (where headers are explicitly controlled)
664
- if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
665
- headers["content-type"] = routeConfig.contentType;
666
-
667
- // Handle special conversion cases when contentType is explicitly set
668
- if (routeConfig.contentType === "text/plain" && body !== undefined) {
669
- if (typeof body === "object" && !Buffer.isBuffer(body)) {
670
- body = JSON.stringify(body);
671
- } else if (typeof body !== "string") {
672
- body = String(body);
673
- }
674
- }
675
- }
676
-
677
- return {
678
- status,
679
- body,
680
- headers,
681
- };
682
- }
683
-
684
- /**
685
- * Run all registered plugins in sequence
686
- * First plugin to set response becomes generator, subsequent plugins transform
687
- * Handles plugin errors via onError hooks
688
- * @param context - Plugin context with request details
689
- * @param initialResponse - Initial response from route generator
690
- * @param _routeConfig - Route config (unused but kept for signature)
691
- * @param _requestId - Request ID (unused but kept for signature)
692
- * @returns Updated context and final response after all plugins
693
- * @private
694
- */
695
- private async runPluginPipeline(
696
- context: Schmock.PluginContext,
697
- initialResponse?: unknown,
698
- _routeConfig?: Schmock.RouteConfig,
699
- _requestId?: string,
700
- ): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
701
- let currentContext = context;
702
- let response: unknown = initialResponse;
703
-
704
- this.logger.log(
705
- "pipeline",
706
- `Running plugin pipeline for ${this.plugins.length} plugins`,
707
- );
708
-
709
- for (const plugin of this.plugins) {
710
- this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
711
-
712
- try {
713
- const result = await plugin.process(currentContext, response);
714
-
715
- if (!result || !result.context) {
716
- throw new Error(`Plugin ${plugin.name} didn't return valid result`);
717
- }
718
-
719
- currentContext = result.context;
720
-
721
- // First plugin to set response becomes the generator
722
- if (
723
- result.response !== undefined &&
724
- (response === undefined || response === null)
725
- ) {
726
- this.logger.log(
727
- "pipeline",
728
- `Plugin ${plugin.name} generated response`,
729
- );
730
- response = result.response;
731
- } else if (result.response !== undefined && response !== undefined) {
732
- this.logger.log(
733
- "pipeline",
734
- `Plugin ${plugin.name} transformed response`,
735
- );
736
- response = result.response;
737
- }
738
- } catch (error) {
739
- this.logger.log(
740
- "pipeline",
741
- `Plugin ${plugin.name} failed: ${errorMessage(error)}`,
742
- );
743
-
744
- // Try error handling if plugin has onError hook
745
- if (plugin.onError) {
746
- try {
747
- const pluginError =
748
- error instanceof Error ? error : new Error(errorMessage(error));
749
- const errorResult = await plugin.onError(
750
- pluginError,
751
- currentContext,
752
- );
753
- if (errorResult) {
754
- this.logger.log(
755
- "pipeline",
756
- `Plugin ${plugin.name} handled error`,
757
- );
758
-
759
- // Error return → transform the thrown error
760
- if (errorResult instanceof Error) {
761
- throw new PluginError(plugin.name, errorResult);
762
- }
763
-
764
- // ResponseResult return → recover, stop pipeline
765
- if (
766
- typeof errorResult === "object" &&
767
- errorResult !== null &&
768
- "status" in errorResult
769
- ) {
770
- response = errorResult;
771
- break;
772
- }
773
- }
774
- // void/falsy return → propagate original error below
775
- } catch (hookError) {
776
- // If the hook itself threw (including our PluginError above), re-throw it
777
- if (hookError instanceof PluginError) {
778
- throw hookError;
779
- }
780
- this.logger.log(
781
- "pipeline",
782
- `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
783
- );
784
- }
785
- }
786
-
787
- const cause =
788
- error instanceof Error ? error : new Error(errorMessage(error));
789
- throw new PluginError(plugin.name, cause);
790
- }
791
- }
792
-
793
- return { context: currentContext, response };
794
- }
795
-
796
- /**
797
- * Find a route that matches the given method and path
798
- * Uses two-pass matching: static routes first, then parameterized routes
799
- * Matches routes in registration order (first registered wins)
800
- * @param method - HTTP method to match
801
- * @param path - Request path to match
802
- * @returns Matched compiled route or undefined if no match
803
- * @private
804
- */
805
- private findRoute(
806
- method: Schmock.HttpMethod,
807
- path: string,
808
- ): CompiledCallableRoute | undefined {
809
- // O(1) lookup for static routes
810
- const normalizedPath =
811
- path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
812
- const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
813
- if (staticMatch) {
814
- return staticMatch;
815
- }
816
-
817
- // Fall through to parameterized route scan
818
- for (const route of this.routes) {
819
- if (
820
- route.method === method &&
821
- route.params.length > 0 &&
822
- route.pattern.test(path)
823
- ) {
824
- return route;
825
- }
826
- }
827
-
828
- return undefined;
829
- }
830
-
831
- /**
832
- * Extract parameter values from path based on route pattern
833
- * Maps capture groups from regex match to parameter names
834
- * @param route - Compiled route with pattern and param names
835
- * @param path - Request path to extract values from
836
- * @returns Object mapping parameter names to extracted values
837
- * @private
838
- */
839
- private extractParams(
840
- route: CompiledCallableRoute,
841
- path: string,
842
- ): Record<string, string> {
843
- const match = path.match(route.pattern);
844
- if (!match) return {};
845
-
846
- const params: Record<string, string> = {};
847
- route.params.forEach((param, index) => {
848
- params[param] = match[index + 1];
849
- });
850
-
851
- return params;
852
- }
853
604
  }
package/src/errors.ts CHANGED
@@ -1,3 +1,7 @@
1
+ export function errorMessage(error: unknown): string {
2
+ return error instanceof Error ? error.message : "Unknown error";
3
+ }
4
+
1
5
  /**
2
6
  * Base error class for all Schmock errors
3
7
  */
package/src/index.ts CHANGED
@@ -52,14 +52,20 @@ export function schmock(
52
52
  reset: instance.reset.bind(instance),
53
53
  resetHistory: instance.resetHistory.bind(instance),
54
54
  resetState: instance.resetState.bind(instance),
55
- on: ((event: string, listener: (data: unknown) => void) => {
55
+ on<E extends Schmock.SchmockEvent>(
56
+ event: E,
57
+ listener: (data: Schmock.SchmockEventMap[E]) => void,
58
+ ) {
56
59
  instance.on(event, listener);
57
60
  return callableInstance;
58
- }) as Schmock.CallableMockInstance["on"],
59
- off: ((event: string, listener: (data: unknown) => void) => {
61
+ },
62
+ off<E extends Schmock.SchmockEvent>(
63
+ event: E,
64
+ listener: (data: Schmock.SchmockEventMap[E]) => void,
65
+ ) {
60
66
  instance.off(event, listener);
61
67
  return callableInstance;
62
- }) as Schmock.CallableMockInstance["off"],
68
+ },
63
69
  getRoutes: instance.getRoutes.bind(instance),
64
70
  getState: instance.getState.bind(instance),
65
71
  listen: instance.listen.bind(instance),
@@ -0,0 +1,100 @@
1
+ import { errorMessage, PluginError } from "./errors.js";
2
+
3
+ /** Structural typing — DebugLogger satisfies this without an import */
4
+ interface PipelineLogger {
5
+ log(category: string, message: string, data?: unknown): void;
6
+ }
7
+
8
+ /**
9
+ * Run all registered plugins in sequence
10
+ * First plugin to set response becomes generator, subsequent plugins transform
11
+ * Handles plugin errors via onError hooks
12
+ */
13
+ export async function runPluginPipeline(
14
+ plugins: Schmock.Plugin[],
15
+ context: Schmock.PluginContext,
16
+ initialResponse: unknown,
17
+ logger: PipelineLogger,
18
+ ): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
19
+ let currentContext = context;
20
+ let response: unknown = initialResponse;
21
+
22
+ logger.log(
23
+ "pipeline",
24
+ `Running plugin pipeline for ${plugins.length} plugins`,
25
+ );
26
+
27
+ for (const plugin of plugins) {
28
+ logger.log("pipeline", `Processing plugin: ${plugin.name}`);
29
+
30
+ try {
31
+ const result = await plugin.process(currentContext, response);
32
+
33
+ if (!result || !result.context) {
34
+ throw new Error(`Plugin ${plugin.name} didn't return valid result`);
35
+ }
36
+
37
+ currentContext = result.context;
38
+
39
+ // First plugin to set response becomes the generator
40
+ if (
41
+ result.response !== undefined &&
42
+ (response === undefined || response === null)
43
+ ) {
44
+ logger.log("pipeline", `Plugin ${plugin.name} generated response`);
45
+ response = result.response;
46
+ } else if (result.response !== undefined && response !== undefined) {
47
+ logger.log("pipeline", `Plugin ${plugin.name} transformed response`);
48
+ response = result.response;
49
+ }
50
+ } catch (error) {
51
+ logger.log(
52
+ "pipeline",
53
+ `Plugin ${plugin.name} failed: ${errorMessage(error)}`,
54
+ );
55
+
56
+ // Try error handling if plugin has onError hook
57
+ if (plugin.onError) {
58
+ try {
59
+ const pluginError =
60
+ error instanceof Error ? error : new Error(errorMessage(error));
61
+ const errorResult = await plugin.onError(pluginError, currentContext);
62
+ if (errorResult) {
63
+ logger.log("pipeline", `Plugin ${plugin.name} handled error`);
64
+
65
+ // Error return → transform the thrown error
66
+ if (errorResult instanceof Error) {
67
+ throw new PluginError(plugin.name, errorResult);
68
+ }
69
+
70
+ // ResponseResult return → recover, stop pipeline
71
+ if (
72
+ typeof errorResult === "object" &&
73
+ errorResult !== null &&
74
+ "status" in errorResult
75
+ ) {
76
+ response = errorResult;
77
+ break;
78
+ }
79
+ }
80
+ // void/falsy return → propagate original error below
81
+ } catch (hookError) {
82
+ // If the hook itself threw (including our PluginError above), re-throw it
83
+ if (hookError instanceof PluginError) {
84
+ throw hookError;
85
+ }
86
+ logger.log(
87
+ "pipeline",
88
+ `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
89
+ );
90
+ }
91
+ }
92
+
93
+ const cause =
94
+ error instanceof Error ? error : new Error(errorMessage(error));
95
+ throw new PluginError(plugin.name, cause);
96
+ }
97
+ }
98
+
99
+ return { context: currentContext, response };
100
+ }
@@ -0,0 +1,74 @@
1
+ import { isStatusTuple } from "./constants.js";
2
+
3
+ function isResponseObject(value: unknown): value is {
4
+ status: number;
5
+ body: unknown;
6
+ headers?: Record<string, string>;
7
+ } {
8
+ return (
9
+ typeof value === "object" &&
10
+ value !== null &&
11
+ "status" in value &&
12
+ "body" in value
13
+ );
14
+ }
15
+
16
+ /**
17
+ * Parse and normalize response result into Response object
18
+ * Handles tuple format [status, body, headers], direct values, and response objects
19
+ */
20
+ export function parseResponse(
21
+ result: unknown,
22
+ routeConfig: Schmock.RouteConfig,
23
+ ): Schmock.Response {
24
+ let status = 200;
25
+ let body: unknown = result;
26
+ let headers: Record<string, string> = {};
27
+
28
+ let tupleFormat = false;
29
+
30
+ // Handle already-formed response objects (from plugin error recovery)
31
+ if (isResponseObject(result)) {
32
+ return {
33
+ status: result.status,
34
+ body: result.body,
35
+ headers: result.headers || {},
36
+ };
37
+ }
38
+
39
+ // Handle tuple response format [status, body, headers?]
40
+ if (isStatusTuple(result)) {
41
+ [status, body, headers = {}] = result;
42
+ tupleFormat = true;
43
+ }
44
+
45
+ // Handle null/undefined responses with 204 No Content
46
+ // But don't auto-convert if tuple format was used (status was explicitly provided)
47
+ if (body === null || body === undefined) {
48
+ if (!tupleFormat) {
49
+ status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
50
+ }
51
+ body = undefined; // Ensure body is undefined for null responses
52
+ }
53
+
54
+ // Add content-type header from route config if it exists and headers don't already have it
55
+ // But only if this isn't a tuple response (where headers are explicitly controlled)
56
+ if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
57
+ headers["content-type"] = routeConfig.contentType;
58
+
59
+ // Handle special conversion cases when contentType is explicitly set
60
+ if (routeConfig.contentType === "text/plain" && body !== undefined) {
61
+ if (typeof body === "object" && !Buffer.isBuffer(body)) {
62
+ body = JSON.stringify(body);
63
+ } else if (typeof body !== "string") {
64
+ body = String(body);
65
+ }
66
+ }
67
+ }
68
+
69
+ return {
70
+ status,
71
+ body,
72
+ headers,
73
+ };
74
+ }
@@ -0,0 +1,69 @@
1
+ /**
2
+ * Compiled callable route with pattern matching
3
+ */
4
+ export interface CompiledCallableRoute {
5
+ pattern: RegExp;
6
+ params: string[];
7
+ method: Schmock.HttpMethod;
8
+ path: string;
9
+ generator: Schmock.Generator;
10
+ config: Schmock.RouteConfig;
11
+ }
12
+
13
+ export function isGeneratorFunction(
14
+ gen: Schmock.Generator,
15
+ ): gen is Schmock.GeneratorFunction {
16
+ return typeof gen === "function";
17
+ }
18
+
19
+ /**
20
+ * Find a route that matches the given method and path
21
+ * Uses two-pass matching: static routes first, then parameterized routes
22
+ * Matches routes in registration order (first registered wins)
23
+ */
24
+ export function findRoute(
25
+ method: Schmock.HttpMethod,
26
+ path: string,
27
+ staticRoutes: Map<string, CompiledCallableRoute>,
28
+ routes: CompiledCallableRoute[],
29
+ ): CompiledCallableRoute | undefined {
30
+ // 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}`);
34
+ if (staticMatch) {
35
+ return staticMatch;
36
+ }
37
+
38
+ // Fall through to parameterized route scan
39
+ for (const route of routes) {
40
+ if (
41
+ route.method === method &&
42
+ route.params.length > 0 &&
43
+ route.pattern.test(path)
44
+ ) {
45
+ return route;
46
+ }
47
+ }
48
+
49
+ return undefined;
50
+ }
51
+
52
+ /**
53
+ * Extract parameter values from path based on route pattern
54
+ * Maps capture groups from regex match to parameter names
55
+ */
56
+ export function extractParams(
57
+ route: CompiledCallableRoute,
58
+ path: string,
59
+ ): Record<string, string> {
60
+ const match = path.match(route.pattern);
61
+ if (!match) return {};
62
+
63
+ const params: Record<string, string> = {};
64
+ route.params.forEach((param, index) => {
65
+ params[param] = match[index + 1];
66
+ });
67
+
68
+ return params;
69
+ }