@schmock/core 2.4.0 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/README.md +129 -0
  2. package/dist/abort.d.ts +11 -1
  3. package/dist/abort.js +13 -2
  4. package/dist/adapter.d.ts +19 -0
  5. package/dist/adapter.js +17 -0
  6. package/dist/admission.d.ts +21 -0
  7. package/dist/admission.js +39 -0
  8. package/dist/binary.d.ts +0 -1
  9. package/dist/builder.d.ts +16 -32
  10. package/dist/builder.js +397 -903
  11. package/dist/constants.d.ts +33 -2
  12. package/dist/constants.js +73 -1
  13. package/dist/debug-logger.d.ts +10 -0
  14. package/dist/debug-logger.js +31 -0
  15. package/dist/delay.d.ts +12 -0
  16. package/dist/delay.js +37 -0
  17. package/dist/errors.d.ts +13 -2
  18. package/dist/errors.js +21 -2
  19. package/dist/events.d.ts +17 -0
  20. package/dist/events.js +58 -0
  21. package/dist/generations.d.ts +43 -0
  22. package/dist/generations.js +75 -0
  23. package/dist/headers.d.ts +27 -0
  24. package/dist/headers.js +57 -0
  25. package/dist/helpers.d.ts +9 -10
  26. package/dist/helpers.js +4 -1
  27. package/dist/history.d.ts +56 -0
  28. package/dist/history.js +230 -0
  29. package/dist/http-helpers.d.ts +110 -5
  30. package/dist/http-helpers.js +328 -46
  31. package/dist/index.d.ts +213 -31
  32. package/dist/index.js +17 -9
  33. package/dist/interceptor.d.ts +15 -11
  34. package/dist/interceptor.js +241 -164
  35. package/dist/node-server.d.ts +27 -0
  36. package/dist/node-server.js +166 -0
  37. package/dist/parser.d.ts +0 -1
  38. package/dist/parser.js +145 -22
  39. package/dist/plugin-hooks.d.ts +40 -0
  40. package/dist/plugin-hooks.js +192 -0
  41. package/dist/plugin-pipeline.d.ts +0 -1
  42. package/dist/plugin-pipeline.js +25 -4
  43. package/dist/response-normalizer.d.ts +36 -1
  44. package/dist/response-normalizer.js +102 -0
  45. package/dist/response-parser.d.ts +19 -1
  46. package/dist/response-parser.js +77 -19
  47. package/dist/route-matcher.d.ts +0 -1
  48. package/dist/route-table.d.ts +64 -0
  49. package/dist/route-table.js +220 -0
  50. package/dist/types.d.ts +27 -1
  51. package/package.json +8 -3
  52. package/dist/abort.d.ts.map +0 -1
  53. package/dist/binary.d.ts.map +0 -1
  54. package/dist/builder.d.ts.map +0 -1
  55. package/dist/constants.d.ts.map +0 -1
  56. package/dist/errors.d.ts.map +0 -1
  57. package/dist/helpers.d.ts.map +0 -1
  58. package/dist/http-helpers.d.ts.map +0 -1
  59. package/dist/index.d.ts.map +0 -1
  60. package/dist/interceptor.d.ts.map +0 -1
  61. package/dist/parser.d.ts.map +0 -1
  62. package/dist/plugin-pipeline.d.ts.map +0 -1
  63. package/dist/response-normalizer.d.ts.map +0 -1
  64. package/dist/response-parser.d.ts.map +0 -1
  65. package/dist/route-matcher.d.ts.map +0 -1
  66. package/dist/types.d.ts.map +0 -1
package/README.md CHANGED
@@ -24,6 +24,135 @@ const response = await mock.handle("GET", "/users");
24
24
  // → { status: 200, body: [{ id: 1, name: "Alice" }] }
25
25
  ```
26
26
 
27
+ ## For adapter and plugin authors
28
+
29
+ The root entry also exports the helpers core's own adapters and plugins are
30
+ built from. Application code does not need them.
31
+
32
+ ```typescript
33
+ // Response results, for plugins
34
+ getResponseParts(response: unknown): ResponseParts // { status, body, headers, kind }
35
+ replaceResponseBody(response: unknown, body: unknown): unknown
36
+
37
+ // Path prefixes: the namespace and baseUrl rule
38
+ parsePathPrefix(prefix: string): PathPrefix // { origin: string | null, path: string }
39
+ matchPathPrefix(prefix: PathPrefix, path: string): boolean
40
+
41
+ // Node ingress: the bridge mock.listen() and the CLI run
42
+ serveNodeRequest(req, res, options: ServeNodeRequestOptions): Promise<void>
43
+
44
+ // Response shaping
45
+ withDefaultContentType(response: Response): Response
46
+ buildFormattedErrorResponse(options: FormattedErrorOptions): Response
47
+
48
+ // Headers
49
+ SENSITIVE_HEADER_NAMES: ReadonlySet<string>
50
+ redactHeaders(headers: Record<string, string>): Record<string, string>
51
+ getHeader(headers: Readonly<Record<string, string>> | undefined, name: string): string | undefined
52
+ ```
53
+
54
+ A plugin that reshapes a body reads the result with `getResponseParts()` and
55
+ writes it back with `replaceResponseBody()`, which apply core's envelope rule:
56
+
57
+ ```typescript
58
+ import { getResponseParts, replaceResponseBody } from "@schmock/core";
59
+
60
+ const wrapPlugin: Schmock.Plugin = {
61
+ name: "wrap",
62
+ process(context, response) {
63
+ const { status, body } = getResponseParts(response);
64
+ if (body == null || status >= 300 || context.requestShortCircuited) {
65
+ return { context, response };
66
+ }
67
+ return { context, response: replaceResponseBody(response, { data: body }) };
68
+ },
69
+ };
70
+ ```
71
+
72
+ A prefix matches on a segment boundary, and one trailing slash is ignored:
73
+
74
+ ```typescript
75
+ import { matchPathPrefix, parsePathPrefix } from "@schmock/core";
76
+
77
+ const prefix = parsePathPrefix("/api/"); // { origin: null, path: "/api" }
78
+ matchPathPrefix(prefix, "/api/users"); // true
79
+ matchPathPrefix(prefix, "/apiv2"); // false
80
+ ```
81
+
82
+ `serveNodeRequest()` serves a mock from any Node server, with the same 400,
83
+ 405 and 413 answers as `mock.listen()`:
84
+
85
+ ```typescript
86
+ import { createServer } from "node:http";
87
+ import { schmock, serveNodeRequest } from "@schmock/core";
88
+
89
+ const mock = schmock();
90
+ mock("GET /users", [{ id: 1 }]);
91
+
92
+ createServer((req, res) => {
93
+ void serveNodeRequest(req, res, { handle: mock.handle, maxBodySize: 1024 * 1024 });
94
+ }).listen(3000);
95
+ ```
96
+
97
+ `withDefaultContentType()` adds the content type a body implies, and
98
+ `buildFormattedErrorResponse()` turns an `errorFormatter` result into a
99
+ normalized 500 without ever throwing. The header helpers mask credentials and
100
+ look names up case-insensitively:
101
+
102
+ ```typescript
103
+ import { buildFormattedErrorResponse, getHeader, redactHeaders, withDefaultContentType } from "@schmock/core";
104
+
105
+ withDefaultContentType({ status: 200, body: { ok: true }, headers: {} });
106
+ // → { status: 200, body: { ok: true }, headers: { "content-type": "application/json" } }
107
+
108
+ buildFormattedErrorResponse({
109
+ formatter: (error) => ({ message: error.message }),
110
+ error: new Error("boom"),
111
+ method: "GET",
112
+ });
113
+ // → { status: 500, body: { message: "boom" }, headers: { "content-type": "application/json" } }
114
+
115
+ redactHeaders({ Authorization: "Bearer t" }); // → { Authorization: "[redacted]" }
116
+ getHeader({ "Content-Type": "text/plain" }, "content-type"); // → "text/plain"
117
+ ```
118
+
119
+ ### `@schmock/core/adapter`
120
+
121
+ Transport adapters import the request-admission protocol from a separate entry:
122
+
123
+ ```typescript
124
+ import { schmock } from "@schmock/core";
125
+ import { acquireRequestAdmission } from "@schmock/core/adapter";
126
+
127
+ const mock = schmock();
128
+ mock("GET /users", [{ id: 1 }]);
129
+
130
+ const admission = acquireRequestAdmission(mock); // undefined for a non-schmock stub
131
+ if (admission) {
132
+ try {
133
+ await admission.handle("GET", "/users");
134
+ } finally {
135
+ admission.release();
136
+ }
137
+ }
138
+ ```
139
+
140
+ It exports `acquireRequestAdmission`, `awaitWithAbort`, `abortReason` and
141
+ `createFetchInterceptor`, and the `RequestAdmission` and `MockRequestHandler`
142
+ types.
143
+
144
+ ### Deprecated
145
+
146
+ These are planned for removal in the next major version:
147
+
148
+ - `createFetchInterceptor` from `@schmock/core`. Use `mock.intercept()`; adapter
149
+ authors import it from `@schmock/core/adapter`.
150
+ - `ExpressAdapterOptions` from `@schmock/core`. Import it from `@schmock/express`.
151
+ - `AngularAdapterOptions` from `@schmock/core`. Import it from `@schmock/angular`.
152
+
153
+ See [Adapter-author utilities](https://github.com/khalic-lab/schmock/blob/main/docs/api.md#adapter-author-utilities)
154
+ for every signature.
155
+
27
156
  ## Documentation
28
157
 
29
158
  - [Getting started](https://github.com/khalic-lab/schmock/blob/main/docs/getting-started.md)
package/dist/abort.d.ts CHANGED
@@ -1,3 +1,13 @@
1
+ /**
2
+ * The reason an aborted signal carries, or a generic `AbortError` for a
3
+ * runtime whose signals predate `reason`.
4
+ */
5
+ export declare function abortReason(signal: AbortSignal): unknown;
1
6
  export declare function throwIfAborted(signal?: AbortSignal): void;
7
+ /**
8
+ * Settle with `value`, or reject with the signal's abort reason as soon as the
9
+ * signal aborts, whichever comes first. Without a signal it only awaits the
10
+ * value. An already-aborted signal rejects instead of throwing, so a caller
11
+ * that does not `await` the result still sees the abort as a rejection.
12
+ */
2
13
  export declare function awaitWithAbort<T>(value: T | PromiseLike<T>, signal?: AbortSignal): Promise<T>;
3
- //# sourceMappingURL=abort.d.ts.map
package/dist/abort.js CHANGED
@@ -1,4 +1,8 @@
1
- function abortReason(signal) {
1
+ /**
2
+ * The reason an aborted signal carries, or a generic `AbortError` for a
3
+ * runtime whose signals predate `reason`.
4
+ */
5
+ export function abortReason(signal) {
2
6
  if ("reason" in signal && signal.reason !== undefined) {
3
7
  return signal.reason;
4
8
  }
@@ -10,10 +14,17 @@ export function throwIfAborted(signal) {
10
14
  if (signal?.aborted)
11
15
  throw abortReason(signal);
12
16
  }
17
+ /**
18
+ * Settle with `value`, or reject with the signal's abort reason as soon as the
19
+ * signal aborts, whichever comes first. Without a signal it only awaits the
20
+ * value. An already-aborted signal rejects instead of throwing, so a caller
21
+ * that does not `await` the result still sees the abort as a rejection.
22
+ */
13
23
  export function awaitWithAbort(value, signal) {
14
24
  if (!signal)
15
25
  return Promise.resolve(value);
16
- throwIfAborted(signal);
26
+ if (signal.aborted)
27
+ return Promise.reject(abortReason(signal));
17
28
  return new Promise((resolve, reject) => {
18
29
  let settled = false;
19
30
  const finish = (action) => {
@@ -0,0 +1,19 @@
1
+ /**
2
+ * `@schmock/core/adapter`: the low-level protocol transport adapters are built
3
+ * on. Application code uses `mock.handle()`, `mock.listen()` and
4
+ * `mock.intercept()` from `@schmock/core` instead.
5
+ *
6
+ * - `acquireRequestAdmission` pins a request to the mock's routes and plugins
7
+ * at arrival, so a concurrent `reset()` cannot change them mid-request.
8
+ * - `awaitWithAbort` / `abortReason` race a hook or handler against the
9
+ * request's abort signal.
10
+ * - `createFetchInterceptor` is the fetch interception `mock.intercept()` is
11
+ * built on.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ export { abortReason, awaitWithAbort } from "./abort.js";
16
+ export { acquireRequestAdmission } from "./admission.js";
17
+ export type { CallableMockInstance, InterceptHandle, InterceptOptions, } from "./index.js";
18
+ export { createFetchInterceptor } from "./interceptor.js";
19
+ export type { MockRequestHandler, RequestAdmission } from "./types.js";
@@ -0,0 +1,17 @@
1
+ /**
2
+ * `@schmock/core/adapter`: the low-level protocol transport adapters are built
3
+ * on. Application code uses `mock.handle()`, `mock.listen()` and
4
+ * `mock.intercept()` from `@schmock/core` instead.
5
+ *
6
+ * - `acquireRequestAdmission` pins a request to the mock's routes and plugins
7
+ * at arrival, so a concurrent `reset()` cannot change them mid-request.
8
+ * - `awaitWithAbort` / `abortReason` race a hook or handler against the
9
+ * request's abort signal.
10
+ * - `createFetchInterceptor` is the fetch interception `mock.intercept()` is
11
+ * built on.
12
+ *
13
+ * @packageDocumentation
14
+ */
15
+ export { abortReason, awaitWithAbort } from "./abort.js";
16
+ export { acquireRequestAdmission } from "./admission.js";
17
+ export { createFetchInterceptor } from "./interceptor.js";
@@ -0,0 +1,21 @@
1
+ /**
2
+ * The key a callable mock carries its request-admission factory under.
3
+ *
4
+ * Registered with `Symbol.for` so that a second copy of `@schmock/core` in the
5
+ * same process (an adapter resolving its own dependency) still finds it.
6
+ */
7
+ export declare const REQUEST_ADMISSION_KEY: unique symbol;
8
+ /**
9
+ * Admit one request against the mock's current routes and plugins.
10
+ *
11
+ * A transport acquires the admission when the request arrives, routes it with
12
+ * `admission.handle`, and calls `admission.release()` once it settles, so a
13
+ * `mock.reset()` issued meanwhile neither changes the routes the request sees
14
+ * nor uninstalls its plugins underneath it.
15
+ *
16
+ * @returns `undefined` for a value that is not a `schmock()` instance (a
17
+ * hand-written stub), which the caller then routes through `mock.handle`.
18
+ * @throws SchmockError `INVALID_REQUEST_ADMISSION` when the mock's factory
19
+ * returns something that is not an admission.
20
+ */
21
+ export declare function acquireRequestAdmission(mock: Schmock.CallableMockInstance): Schmock.RequestAdmission | undefined;
@@ -0,0 +1,39 @@
1
+ import { SchmockError } from "./errors.js";
2
+ /**
3
+ * The key a callable mock carries its request-admission factory under.
4
+ *
5
+ * Registered with `Symbol.for` so that a second copy of `@schmock/core` in the
6
+ * same process (an adapter resolving its own dependency) still finds it.
7
+ */
8
+ export const REQUEST_ADMISSION_KEY = Symbol.for("@schmock/core.request-admission");
9
+ function isRequestAdmission(value) {
10
+ return (typeof value === "object" &&
11
+ value !== null &&
12
+ "handle" in value &&
13
+ typeof value.handle === "function" &&
14
+ "release" in value &&
15
+ typeof value.release === "function");
16
+ }
17
+ /**
18
+ * Admit one request against the mock's current routes and plugins.
19
+ *
20
+ * A transport acquires the admission when the request arrives, routes it with
21
+ * `admission.handle`, and calls `admission.release()` once it settles, so a
22
+ * `mock.reset()` issued meanwhile neither changes the routes the request sees
23
+ * nor uninstalls its plugins underneath it.
24
+ *
25
+ * @returns `undefined` for a value that is not a `schmock()` instance (a
26
+ * hand-written stub), which the caller then routes through `mock.handle`.
27
+ * @throws SchmockError `INVALID_REQUEST_ADMISSION` when the mock's factory
28
+ * returns something that is not an admission.
29
+ */
30
+ export function acquireRequestAdmission(mock) {
31
+ const admit = Reflect.get(mock, REQUEST_ADMISSION_KEY);
32
+ if (typeof admit !== "function")
33
+ return undefined;
34
+ const admission = Reflect.apply(admit, mock, []);
35
+ if (!isRequestAdmission(admission)) {
36
+ throw new SchmockError("Schmock returned an invalid request admission", "INVALID_REQUEST_ADMISSION");
37
+ }
38
+ return admission;
39
+ }
package/dist/binary.d.ts CHANGED
@@ -5,4 +5,3 @@
5
5
  * by SharedArrayBuffer, and Node.js Buffer without referencing its global.
6
6
  */
7
7
  export declare function isBinaryBody(value: unknown): value is ArrayBuffer | ArrayBufferView;
8
- //# sourceMappingURL=binary.d.ts.map
package/dist/builder.d.ts CHANGED
@@ -1,20 +1,19 @@
1
- import type { CompiledCallableRoute } from "./route-matcher.js";
2
- interface RequestAdmission {
1
+ import type { RequestGeneration } from "./generations.js";
2
+ import type { RouteTableSnapshot } from "./route-table.js";
3
+ /**
4
+ * What an admitted request captured at arrival. The transports' public
5
+ * `Schmock.RequestAdmission` wraps one of these.
6
+ */
7
+ interface AdmissionSnapshot {
3
8
  readonly requestGeneration: RequestGeneration;
4
9
  readonly historyGeneration: symbol;
5
10
  readonly plugins: readonly Schmock.Plugin[];
6
- readonly routes: CompiledCallableRoute[];
7
- readonly staticRoutes: Map<string, CompiledCallableRoute>;
11
+ readonly routes: RouteTableSnapshot;
8
12
  readonly state: Record<string, unknown>;
9
13
  readonly namespace?: string;
10
14
  readonly globalDelay?: number | [number, number];
11
- readonly maxHistorySize?: number;
12
15
  released: boolean;
13
16
  }
14
- interface RequestGeneration {
15
- activeAdmissions: number;
16
- retiredPlugins?: readonly Schmock.Plugin[];
17
- }
18
17
  /**
19
18
  * Callable mock instance that implements the new API.
20
19
  *
@@ -22,27 +21,23 @@ interface RequestGeneration {
22
21
  */
23
22
  export declare class CallableMockInstance {
24
23
  #private;
25
- private routes;
26
- private staticRoutes;
24
+ private readonly routeTable;
27
25
  private plugins;
28
26
  private logger;
29
- private requestHistory;
27
+ private readonly requestHistory;
28
+ private readonly nodeServer;
30
29
  private callableRef;
31
- private server;
32
- private pendingServerStart;
33
- private serverCloseBarrier;
34
30
  private interceptHandles;
35
- private requestGeneration;
36
- private historyGeneration;
31
+ private readonly generations;
37
32
  private interceptOwner;
38
33
  private globalConfig;
39
- private listeners;
34
+ private readonly events;
35
+ private namespaceCache;
40
36
  constructor(globalConfig?: Schmock.GlobalConfig);
41
37
  defineRoute(route: Schmock.RouteKey, generator: Schmock.Generator, config: Schmock.RouteConfig): this;
42
38
  setCallableRef(ref: Schmock.CallableMockInstance): void;
43
39
  pipe(plugin: Schmock.Plugin): this;
44
40
  private uninstallPlugins;
45
- private cloneRecord;
46
41
  history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[];
47
42
  called(method?: Schmock.HttpMethod, path?: string): boolean;
48
43
  callCount(method?: Schmock.HttpMethod, path?: string): number;
@@ -51,24 +46,13 @@ export declare class CallableMockInstance {
51
46
  getState(): Record<string, unknown>;
52
47
  on<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): this;
53
48
  off<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): this;
54
- private emit;
55
49
  reset(): void;
56
50
  resetHistory(): void;
57
51
  resetState(): void;
58
- createRequestAdmission(): {
59
- handle: (method: Schmock.HttpMethod, path: string, options?: Schmock.RequestOptions) => Promise<Schmock.Response>;
60
- release: () => void;
61
- };
52
+ createRequestAdmission(): Schmock.RequestAdmission;
62
53
  listen(port?: number, hostname?: string): Promise<Schmock.ServerInfo>;
63
54
  close(): void;
64
55
  intercept(options?: Schmock.InterceptOptions): Schmock.InterceptHandle;
65
- handle(method: Schmock.HttpMethod, path: string, options?: Schmock.RequestOptions, admission?: RequestAdmission): Promise<Schmock.Response>;
66
- /**
67
- * Apply configured response delay
68
- * Supports both fixed delays and random delays within a range
69
- * @private
70
- */
71
- private applyDelay;
56
+ handle(method: Schmock.HttpMethod, path: string, options?: Schmock.RequestOptions, admission?: AdmissionSnapshot): Promise<Schmock.Response>;
72
57
  }
73
58
  export {};
74
- //# sourceMappingURL=builder.d.ts.map