@schmock/core 1.9.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
@@ -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;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;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
  *
@@ -324,7 +315,7 @@ export class CallableMockInstance {
324
315
  requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
325
316
  }
326
317
  // Find matching route
327
- const matchedRoute = this.findRoute(method, requestPath);
318
+ const matchedRoute = findRoute(method, requestPath, this.staticRoutes, this.routes);
328
319
  if (!matchedRoute) {
329
320
  this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
330
321
  this.emit("request:notfound", { method, path: requestPath });
@@ -345,7 +336,7 @@ export class CallableMockInstance {
345
336
  }
346
337
  this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
347
338
  // Extract parameters from the matched route
348
- const params = this.extractParams(matchedRoute, requestPath);
339
+ const params = extractParams(matchedRoute, requestPath);
349
340
  this.emit("request:match", {
350
341
  method,
351
342
  path: requestPath,
@@ -383,7 +374,7 @@ export class CallableMockInstance {
383
374
  };
384
375
  // Run plugin pipeline to transform the response
385
376
  try {
386
- const pipelineResult = await this.runPluginPipeline(pluginContext, result, matchedRoute.config, requestId);
377
+ const pipelineResult = await runPluginPipeline(this.plugins, pluginContext, result, this.logger);
387
378
  pluginContext = pipelineResult.context;
388
379
  result = pipelineResult.response;
389
380
  }
@@ -392,7 +383,7 @@ export class CallableMockInstance {
392
383
  throw error;
393
384
  }
394
385
  // Parse and prepare response
395
- const response = this.parseResponse(result, matchedRoute.config);
386
+ const response = parseResponse(result, matchedRoute.config);
396
387
  // Apply delay (route-level overrides global)
397
388
  await this.applyDelay(matchedRoute.config.delay);
398
389
  // Record request in history
@@ -455,173 +446,4 @@ export class CallableMockInstance {
455
446
  : effectiveDelay;
456
447
  await new Promise((resolve) => setTimeout(resolve, ms));
457
448
  }
458
- /**
459
- * Parse and normalize response result into Response object
460
- * Handles tuple format [status, body, headers], direct values, and response objects
461
- * @param result - Raw result from generator or plugin
462
- * @param routeConfig - Route configuration for content-type defaults
463
- * @returns Normalized Response object with status, body, and headers
464
- * @private
465
- */
466
- parseResponse(result, routeConfig) {
467
- let status = 200;
468
- let body = result;
469
- let headers = {};
470
- let tupleFormat = false;
471
- // Handle already-formed response objects (from plugin error recovery)
472
- if (isResponseObject(result)) {
473
- return {
474
- status: result.status,
475
- body: result.body,
476
- headers: result.headers || {},
477
- };
478
- }
479
- // Handle tuple response format [status, body, headers?]
480
- if (isStatusTuple(result)) {
481
- [status, body, headers = {}] = result;
482
- tupleFormat = true;
483
- }
484
- // Handle null/undefined responses with 204 No Content
485
- // But don't auto-convert if tuple format was used (status was explicitly provided)
486
- if (body === null || body === undefined) {
487
- if (!tupleFormat) {
488
- status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
489
- }
490
- body = undefined; // Ensure body is undefined for null responses
491
- }
492
- // Add content-type header from route config if it exists and headers don't already have it
493
- // But only if this isn't a tuple response (where headers are explicitly controlled)
494
- if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
495
- headers["content-type"] = routeConfig.contentType;
496
- // Handle special conversion cases when contentType is explicitly set
497
- if (routeConfig.contentType === "text/plain" && body !== undefined) {
498
- if (typeof body === "object" && !Buffer.isBuffer(body)) {
499
- body = JSON.stringify(body);
500
- }
501
- else if (typeof body !== "string") {
502
- body = String(body);
503
- }
504
- }
505
- }
506
- return {
507
- status,
508
- body,
509
- headers,
510
- };
511
- }
512
- /**
513
- * Run all registered plugins in sequence
514
- * First plugin to set response becomes generator, subsequent plugins transform
515
- * Handles plugin errors via onError hooks
516
- * @param context - Plugin context with request details
517
- * @param initialResponse - Initial response from route generator
518
- * @param _routeConfig - Route config (unused but kept for signature)
519
- * @param _requestId - Request ID (unused but kept for signature)
520
- * @returns Updated context and final response after all plugins
521
- * @private
522
- */
523
- async runPluginPipeline(context, initialResponse, _routeConfig, _requestId) {
524
- let currentContext = context;
525
- let response = initialResponse;
526
- this.logger.log("pipeline", `Running plugin pipeline for ${this.plugins.length} plugins`);
527
- for (const plugin of this.plugins) {
528
- this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
529
- try {
530
- const result = await plugin.process(currentContext, response);
531
- if (!result || !result.context) {
532
- throw new Error(`Plugin ${plugin.name} didn't return valid result`);
533
- }
534
- currentContext = result.context;
535
- // First plugin to set response becomes the generator
536
- if (result.response !== undefined &&
537
- (response === undefined || response === null)) {
538
- this.logger.log("pipeline", `Plugin ${plugin.name} generated response`);
539
- response = result.response;
540
- }
541
- else if (result.response !== undefined && response !== undefined) {
542
- this.logger.log("pipeline", `Plugin ${plugin.name} transformed response`);
543
- response = result.response;
544
- }
545
- }
546
- catch (error) {
547
- this.logger.log("pipeline", `Plugin ${plugin.name} failed: ${errorMessage(error)}`);
548
- // Try error handling if plugin has onError hook
549
- if (plugin.onError) {
550
- try {
551
- const pluginError = error instanceof Error ? error : new Error(errorMessage(error));
552
- const errorResult = await plugin.onError(pluginError, currentContext);
553
- if (errorResult) {
554
- this.logger.log("pipeline", `Plugin ${plugin.name} handled error`);
555
- // Error return → transform the thrown error
556
- if (errorResult instanceof Error) {
557
- throw new PluginError(plugin.name, errorResult);
558
- }
559
- // ResponseResult return → recover, stop pipeline
560
- if (typeof errorResult === "object" &&
561
- errorResult !== null &&
562
- "status" in errorResult) {
563
- response = errorResult;
564
- break;
565
- }
566
- }
567
- // void/falsy return → propagate original error below
568
- }
569
- catch (hookError) {
570
- // If the hook itself threw (including our PluginError above), re-throw it
571
- if (hookError instanceof PluginError) {
572
- throw hookError;
573
- }
574
- this.logger.log("pipeline", `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`);
575
- }
576
- }
577
- const cause = error instanceof Error ? error : new Error(errorMessage(error));
578
- throw new PluginError(plugin.name, cause);
579
- }
580
- }
581
- return { context: currentContext, response };
582
- }
583
- /**
584
- * Find a route that matches the given method and path
585
- * Uses two-pass matching: static routes first, then parameterized routes
586
- * Matches routes in registration order (first registered wins)
587
- * @param method - HTTP method to match
588
- * @param path - Request path to match
589
- * @returns Matched compiled route or undefined if no match
590
- * @private
591
- */
592
- findRoute(method, path) {
593
- // O(1) lookup for static routes
594
- const normalizedPath = path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
595
- const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
596
- if (staticMatch) {
597
- return staticMatch;
598
- }
599
- // Fall through to parameterized route scan
600
- for (const route of this.routes) {
601
- if (route.method === method &&
602
- route.params.length > 0 &&
603
- route.pattern.test(path)) {
604
- return route;
605
- }
606
- }
607
- return undefined;
608
- }
609
- /**
610
- * Extract parameter values from path based on route pattern
611
- * Maps capture groups from regex match to parameter names
612
- * @param route - Compiled route with pattern and param names
613
- * @param path - Request path to extract values from
614
- * @returns Object mapping parameter names to extracted values
615
- * @private
616
- */
617
- extractParams(route, path) {
618
- const match = path.match(route.pattern);
619
- if (!match)
620
- return {};
621
- const params = {};
622
- route.params.forEach((param, index) => {
623
- params[param] = match[index + 1];
624
- });
625
- return params;
626
- }
627
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
  */
@@ -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.9.0",
4
+ "version": "1.9.1",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "types": "./dist/index.d.ts",
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
  *
@@ -451,7 +424,12 @@ export class CallableMockInstance {
451
424
  }
452
425
 
453
426
  // Find matching route
454
- const matchedRoute = this.findRoute(method, requestPath);
427
+ const matchedRoute = findRoute(
428
+ method,
429
+ requestPath,
430
+ this.staticRoutes,
431
+ this.routes,
432
+ );
455
433
 
456
434
  if (!matchedRoute) {
457
435
  this.logger.log(
@@ -481,7 +459,7 @@ export class CallableMockInstance {
481
459
  );
482
460
 
483
461
  // Extract parameters from the matched route
484
- const params = this.extractParams(matchedRoute, requestPath);
462
+ const params = extractParams(matchedRoute, requestPath);
485
463
 
486
464
  this.emit("request:match", {
487
465
  method,
@@ -523,11 +501,11 @@ export class CallableMockInstance {
523
501
 
524
502
  // Run plugin pipeline to transform the response
525
503
  try {
526
- const pipelineResult = await this.runPluginPipeline(
504
+ const pipelineResult = await runPluginPipeline(
505
+ this.plugins,
527
506
  pluginContext,
528
507
  result,
529
- matchedRoute.config,
530
- requestId,
508
+ this.logger,
531
509
  );
532
510
  pluginContext = pipelineResult.context;
533
511
  result = pipelineResult.response;
@@ -540,7 +518,7 @@ export class CallableMockInstance {
540
518
  }
541
519
 
542
520
  // Parse and prepare response
543
- const response = this.parseResponse(result, matchedRoute.config);
521
+ const response = parseResponse(result, matchedRoute.config);
544
522
 
545
523
  // Apply delay (route-level overrides global)
546
524
  await this.applyDelay(matchedRoute.config.delay);
@@ -623,238 +601,4 @@ export class CallableMockInstance {
623
601
 
624
602
  await new Promise((resolve) => setTimeout(resolve, ms));
625
603
  }
626
-
627
- /**
628
- * Parse and normalize response result into Response object
629
- * Handles tuple format [status, body, headers], direct values, and response objects
630
- * @param result - Raw result from generator or plugin
631
- * @param routeConfig - Route configuration for content-type defaults
632
- * @returns Normalized Response object with status, body, and headers
633
- * @private
634
- */
635
- private parseResponse(
636
- result: unknown,
637
- routeConfig: Schmock.RouteConfig,
638
- ): Schmock.Response {
639
- let status = 200;
640
- let body: unknown = result;
641
- let headers: Record<string, string> = {};
642
-
643
- let tupleFormat = false;
644
-
645
- // Handle already-formed response objects (from plugin error recovery)
646
- if (isResponseObject(result)) {
647
- return {
648
- status: result.status,
649
- body: result.body,
650
- headers: result.headers || {},
651
- };
652
- }
653
-
654
- // Handle tuple response format [status, body, headers?]
655
- if (isStatusTuple(result)) {
656
- [status, body, headers = {}] = result;
657
- tupleFormat = true;
658
- }
659
-
660
- // Handle null/undefined responses with 204 No Content
661
- // But don't auto-convert if tuple format was used (status was explicitly provided)
662
- if (body === null || body === undefined) {
663
- if (!tupleFormat) {
664
- status = status === 200 ? 204 : status; // Only change to 204 if status wasn't explicitly set via tuple
665
- }
666
- body = undefined; // Ensure body is undefined for null responses
667
- }
668
-
669
- // Add content-type header from route config if it exists and headers don't already have it
670
- // But only if this isn't a tuple response (where headers are explicitly controlled)
671
- if (!headers["content-type"] && routeConfig.contentType && !tupleFormat) {
672
- headers["content-type"] = routeConfig.contentType;
673
-
674
- // Handle special conversion cases when contentType is explicitly set
675
- if (routeConfig.contentType === "text/plain" && body !== undefined) {
676
- if (typeof body === "object" && !Buffer.isBuffer(body)) {
677
- body = JSON.stringify(body);
678
- } else if (typeof body !== "string") {
679
- body = String(body);
680
- }
681
- }
682
- }
683
-
684
- return {
685
- status,
686
- body,
687
- headers,
688
- };
689
- }
690
-
691
- /**
692
- * Run all registered plugins in sequence
693
- * First plugin to set response becomes generator, subsequent plugins transform
694
- * Handles plugin errors via onError hooks
695
- * @param context - Plugin context with request details
696
- * @param initialResponse - Initial response from route generator
697
- * @param _routeConfig - Route config (unused but kept for signature)
698
- * @param _requestId - Request ID (unused but kept for signature)
699
- * @returns Updated context and final response after all plugins
700
- * @private
701
- */
702
- private async runPluginPipeline(
703
- context: Schmock.PluginContext,
704
- initialResponse?: unknown,
705
- _routeConfig?: Schmock.RouteConfig,
706
- _requestId?: string,
707
- ): Promise<{ context: Schmock.PluginContext; response?: unknown }> {
708
- let currentContext = context;
709
- let response: unknown = initialResponse;
710
-
711
- this.logger.log(
712
- "pipeline",
713
- `Running plugin pipeline for ${this.plugins.length} plugins`,
714
- );
715
-
716
- for (const plugin of this.plugins) {
717
- this.logger.log("pipeline", `Processing plugin: ${plugin.name}`);
718
-
719
- try {
720
- const result = await plugin.process(currentContext, response);
721
-
722
- if (!result || !result.context) {
723
- throw new Error(`Plugin ${plugin.name} didn't return valid result`);
724
- }
725
-
726
- currentContext = result.context;
727
-
728
- // First plugin to set response becomes the generator
729
- if (
730
- result.response !== undefined &&
731
- (response === undefined || response === null)
732
- ) {
733
- this.logger.log(
734
- "pipeline",
735
- `Plugin ${plugin.name} generated response`,
736
- );
737
- response = result.response;
738
- } else if (result.response !== undefined && response !== undefined) {
739
- this.logger.log(
740
- "pipeline",
741
- `Plugin ${plugin.name} transformed response`,
742
- );
743
- response = result.response;
744
- }
745
- } catch (error) {
746
- this.logger.log(
747
- "pipeline",
748
- `Plugin ${plugin.name} failed: ${errorMessage(error)}`,
749
- );
750
-
751
- // Try error handling if plugin has onError hook
752
- if (plugin.onError) {
753
- try {
754
- const pluginError =
755
- error instanceof Error ? error : new Error(errorMessage(error));
756
- const errorResult = await plugin.onError(
757
- pluginError,
758
- currentContext,
759
- );
760
- if (errorResult) {
761
- this.logger.log(
762
- "pipeline",
763
- `Plugin ${plugin.name} handled error`,
764
- );
765
-
766
- // Error return → transform the thrown error
767
- if (errorResult instanceof Error) {
768
- throw new PluginError(plugin.name, errorResult);
769
- }
770
-
771
- // ResponseResult return → recover, stop pipeline
772
- if (
773
- typeof errorResult === "object" &&
774
- errorResult !== null &&
775
- "status" in errorResult
776
- ) {
777
- response = errorResult;
778
- break;
779
- }
780
- }
781
- // void/falsy return → propagate original error below
782
- } catch (hookError) {
783
- // If the hook itself threw (including our PluginError above), re-throw it
784
- if (hookError instanceof PluginError) {
785
- throw hookError;
786
- }
787
- this.logger.log(
788
- "pipeline",
789
- `Plugin ${plugin.name} error handler failed: ${errorMessage(hookError)}`,
790
- );
791
- }
792
- }
793
-
794
- const cause =
795
- error instanceof Error ? error : new Error(errorMessage(error));
796
- throw new PluginError(plugin.name, cause);
797
- }
798
- }
799
-
800
- return { context: currentContext, response };
801
- }
802
-
803
- /**
804
- * Find a route that matches the given method and path
805
- * Uses two-pass matching: static routes first, then parameterized routes
806
- * Matches routes in registration order (first registered wins)
807
- * @param method - HTTP method to match
808
- * @param path - Request path to match
809
- * @returns Matched compiled route or undefined if no match
810
- * @private
811
- */
812
- private findRoute(
813
- method: Schmock.HttpMethod,
814
- path: string,
815
- ): CompiledCallableRoute | undefined {
816
- // O(1) lookup for static routes
817
- const normalizedPath =
818
- path.endsWith("/") && path !== "/" ? path.slice(0, -1) : path;
819
- const staticMatch = this.staticRoutes.get(`${method} ${normalizedPath}`);
820
- if (staticMatch) {
821
- return staticMatch;
822
- }
823
-
824
- // Fall through to parameterized route scan
825
- for (const route of this.routes) {
826
- if (
827
- route.method === method &&
828
- route.params.length > 0 &&
829
- route.pattern.test(path)
830
- ) {
831
- return route;
832
- }
833
- }
834
-
835
- return undefined;
836
- }
837
-
838
- /**
839
- * Extract parameter values from path based on route pattern
840
- * Maps capture groups from regex match to parameter names
841
- * @param route - Compiled route with pattern and param names
842
- * @param path - Request path to extract values from
843
- * @returns Object mapping parameter names to extracted values
844
- * @private
845
- */
846
- private extractParams(
847
- route: CompiledCallableRoute,
848
- path: string,
849
- ): Record<string, string> {
850
- const match = path.match(route.pattern);
851
- if (!match) return {};
852
-
853
- const params: Record<string, string> = {};
854
- route.params.forEach((param, index) => {
855
- params[param] = match[index + 1];
856
- });
857
-
858
- return params;
859
- }
860
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
  */
@@ -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
+ }