@schmock/core 2.5.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/adapter.d.ts CHANGED
@@ -9,11 +9,13 @@
9
9
  * request's abort signal.
10
10
  * - `createFetchInterceptor` is the fetch interception `mock.intercept()` is
11
11
  * built on.
12
+ * - `acquireFetchRelay` / `routeRelayedRequest` let a relay transport (a
13
+ * service worker) deliver intercepted requests itself.
12
14
  *
13
15
  * @packageDocumentation
14
16
  */
15
17
  export { abortReason, awaitWithAbort } from "./abort.js";
16
18
  export { acquireRequestAdmission } from "./admission.js";
17
19
  export type { CallableMockInstance, InterceptHandle, InterceptOptions, } from "./index.js";
18
- export { createFetchInterceptor } from "./interceptor.js";
19
- export type { MockRequestHandler, RequestAdmission } from "./types.js";
20
+ export { acquireFetchRelay, createFetchInterceptor, routeRelayedRequest, } from "./interceptor.js";
21
+ export type { FetchRelay, MockRequestHandler, RequestAdmission, } from "./types.js";
package/dist/adapter.js CHANGED
@@ -9,9 +9,11 @@
9
9
  * request's abort signal.
10
10
  * - `createFetchInterceptor` is the fetch interception `mock.intercept()` is
11
11
  * built on.
12
+ * - `acquireFetchRelay` / `routeRelayedRequest` let a relay transport (a
13
+ * service worker) deliver intercepted requests itself.
12
14
  *
13
15
  * @packageDocumentation
14
16
  */
15
17
  export { abortReason, awaitWithAbort } from "./abort.js";
16
18
  export { acquireRequestAdmission } from "./admission.js";
17
- export { createFetchInterceptor } from "./interceptor.js";
19
+ export { acquireFetchRelay, createFetchInterceptor, routeRelayedRequest, } from "./interceptor.js";
package/dist/builder.js CHANGED
@@ -7,9 +7,9 @@ import { MockEvents } from "./events.js";
7
7
  import { RequestGenerations } from "./generations.js";
8
8
  import { redactHeaders } from "./headers.js";
9
9
  import { RequestHistory } from "./history.js";
10
- import { createFetchInterceptor, NORMALIZED_ADMISSION_KEY, } from "./interceptor.js";
10
+ import { createFetchLease, NORMALIZED_ADMISSION_KEY, } from "./interceptor.js";
11
11
  import { NodeServerController } from "./node-server.js";
12
- import { assertValidPlugin, runInstallHook, runUninstallHooks, } from "./plugin-hooks.js";
12
+ import { assertValidPlugin, hasExchangeObserver, runExchangeHooks, runInstallHook, runUninstallHooks, } from "./plugin-hooks.js";
13
13
  import { recoverGeneratorError, runPluginBeforeRequest, runPluginPipeline, } from "./plugin-pipeline.js";
14
14
  import { buildJsonErrorResponse, normalizeResponse, } from "./response-normalizer.js";
15
15
  import { parseResponse } from "./response-parser.js";
@@ -224,7 +224,13 @@ export class CallableMockInstance {
224
224
  // slot with their own options, released independently. The owner symbol
225
225
  // keeps them one mock for dispatch, so a single request reaches handle()
226
226
  // once no matter how many leases this instance holds.
227
- const lease = createFetchInterceptor((method, path, opts) => this.handle(method, path, opts), options, () => this.createRequestAdmission(), this.interceptOwner);
227
+ const lease = createFetchLease({
228
+ handle: (method, path, opts) => this.handle(method, path, opts),
229
+ options,
230
+ admitRequest: () => this.createRequestAdmission(),
231
+ owner: this.interceptOwner,
232
+ observe: () => this.#openExchangeObservation(),
233
+ });
228
234
  const handle = {
229
235
  restore: () => {
230
236
  lease.restore();
@@ -243,6 +249,27 @@ export class CallableMockInstance {
243
249
  this.logger.log("lifecycle", `Interception lease acquired (${this.interceptHandles.size} held)`);
244
250
  return handle;
245
251
  }
252
+ /**
253
+ * Called by the lease right before it consults this mock about one request.
254
+ * It captures the plugins and the generation the request is admitted under,
255
+ * so observers piped later, or retired by reset(), never see it.
256
+ */
257
+ #openExchangeObservation() {
258
+ const plugins = this.plugins; // replaced, never mutated
259
+ if (!hasExchangeObserver(plugins))
260
+ return undefined; // no exchange is built
261
+ const generation = this.generations.current;
262
+ // Same gate as events and history, checked before each observer: one
263
+ // observer may reset() the mock and uninstall the ones after it.
264
+ return (exchange) => {
265
+ runExchangeHooks({
266
+ plugins,
267
+ exchange,
268
+ logger: this.logger,
269
+ isLive: () => this.generations.isCurrent(generation),
270
+ });
271
+ };
272
+ }
246
273
  // ===== Request Handling =====
247
274
  async handle(method, path, options, admission) {
248
275
  const requestAdmission = admission ?? this.#captureRequestAdmission();
@@ -18,6 +18,11 @@ export declare class RequestGenerations {
18
18
  * emit lifecycle events and record history.
19
19
  */
20
20
  isCurrent(generation: RequestGeneration): boolean;
21
+ /**
22
+ * The live generation, for a caller to compare later with `isCurrent()`.
23
+ * Reading it admits nothing and never runs an uninstall.
24
+ */
25
+ get current(): RequestGeneration;
21
26
  /** Count one more in-flight request in the current generation. */
22
27
  admit(): RequestGeneration;
23
28
  /**
@@ -18,6 +18,13 @@ export class RequestGenerations {
18
18
  isCurrent(generation) {
19
19
  return generation === this.#current;
20
20
  }
21
+ /**
22
+ * The live generation, for a caller to compare later with `isCurrent()`.
23
+ * Reading it admits nothing and never runs an uninstall.
24
+ */
25
+ get current() {
26
+ return this.#current;
27
+ }
21
28
  /** Count one more in-flight request in the current generation. */
22
29
  admit() {
23
30
  const generation = this.#current;
package/dist/history.js CHANGED
@@ -1,62 +1,6 @@
1
1
  import { canonicalizePath, normalizePath } from "./constants.js";
2
2
  import { SchmockError } from "./errors.js";
3
- function unavailableHistoryValue(value) {
4
- let type = typeof value;
5
- if (typeof value === "object" && value !== null) {
6
- try {
7
- type = Object.prototype.toString.call(value);
8
- }
9
- catch {
10
- type = "object";
11
- }
12
- }
13
- return {
14
- kind: "unavailable",
15
- reason: "not-structured-cloneable",
16
- type,
17
- };
18
- }
19
- function removeSharedMemory(value, seen = new WeakMap()) {
20
- if (typeof value !== "object" || value === null)
21
- return value;
22
- const existing = seen.get(value);
23
- if (existing !== undefined)
24
- return existing;
25
- if (typeof SharedArrayBuffer !== "undefined" &&
26
- value instanceof SharedArrayBuffer) {
27
- const copy = Uint8Array.from(new Uint8Array(value)).buffer;
28
- seen.set(value, copy);
29
- return copy;
30
- }
31
- if (ArrayBuffer.isView(value) &&
32
- typeof SharedArrayBuffer !== "undefined" &&
33
- value.buffer instanceof SharedArrayBuffer) {
34
- const copy = Uint8Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
35
- seen.set(value, copy);
36
- return copy;
37
- }
38
- seen.set(value, value);
39
- if (value instanceof Map) {
40
- const entries = [...value.entries()];
41
- value.clear();
42
- for (const [key, entryValue] of entries) {
43
- value.set(removeSharedMemory(key, seen), removeSharedMemory(entryValue, seen));
44
- }
45
- return value;
46
- }
47
- if (value instanceof Set) {
48
- const entries = [...value.values()];
49
- value.clear();
50
- for (const entryValue of entries) {
51
- value.add(removeSharedMemory(entryValue, seen));
52
- }
53
- return value;
54
- }
55
- for (const key of Reflect.ownKeys(value)) {
56
- Reflect.set(value, key, removeSharedMemory(Reflect.get(value, key), seen));
57
- }
58
- return value;
59
- }
3
+ import { snapshotNormalizedBody, snapshotValue } from "./snapshot.js";
60
4
  /**
61
5
  * Reject a history limit that cannot bound anything.
62
6
  *
@@ -72,29 +16,6 @@ function assertValidHistoryLimit(limit) {
72
16
  throw new SchmockError(`Invalid maxHistorySize: ${String(limit)}. Expected a non-negative integer (0 disables history).`, "INVALID_CONFIG", { maxHistorySize: limit });
73
17
  }
74
18
  }
75
- function snapshotHistoryValue(value) {
76
- try {
77
- return removeSharedMemory(structuredClone(value));
78
- }
79
- catch {
80
- return unavailableHistoryValue(value);
81
- }
82
- }
83
- /**
84
- * Snapshot a body that already went through `normalizeResponse`.
85
- *
86
- * A normalized body is a string, a `JSON.parse` tree or a fresh byte copy, so
87
- * it can never hold shared memory: the `removeSharedMemory` walk that caller
88
- * supplied values need would only re-visit every node for nothing.
89
- */
90
- function snapshotNormalizedBody(value) {
91
- try {
92
- return structuredClone(value);
93
- }
94
- catch {
95
- return unavailableHistoryValue(value);
96
- }
97
- }
98
19
  function cloneRecord(r) {
99
20
  return {
100
21
  method: r.method,
@@ -102,7 +23,7 @@ function cloneRecord(r) {
102
23
  params: { ...r.params },
103
24
  query: { ...r.query },
104
25
  headers: { ...r.headers },
105
- body: snapshotHistoryValue(r.body),
26
+ body: snapshotValue(r.body),
106
27
  timestamp: r.timestamp,
107
28
  response: {
108
29
  status: r.response.status,
@@ -158,7 +79,7 @@ export class RequestHistory {
158
79
  return {
159
80
  query: { ...request.query },
160
81
  headers: { ...request.headers },
161
- body: snapshotHistoryValue(request.body),
82
+ body: snapshotValue(request.body),
162
83
  };
163
84
  }
164
85
  /** Record a matched request, unless its history generation has ended. */
package/dist/index.d.ts CHANGED
@@ -192,6 +192,22 @@ namespace Schmock {
192
192
  error: Error,
193
193
  context: PluginContext,
194
194
  ): Error | ResponseResult | void | Promise<Error | ResponseResult | void>;
195
+
196
+ /**
197
+ * Observe what a client finally received. Called once for each request this
198
+ * mock settled through fetch interception (`mock.intercept()`, and the
199
+ * service-worker relay built on its leases): answered, failed, or aborted
200
+ * while the mock was answering it. It runs after every adapter hook, so it
201
+ * sees `beforeResponse` and `errorFormatter` output, the 404 for an
202
+ * unrouted request and the 400 for a malformed JSON body when passthrough
203
+ * is off. Not called for requests passed on to the network, for
204
+ * `mock.handle()`, or for requests that arrived before the last `reset()`
205
+ * or before this plugin was piped. Observation only: each observer gets its
206
+ * own frozen snapshot (bodies are copies), the return value is ignored, and
207
+ * a throw or rejection is logged under the `plugin` debug category without
208
+ * reaching the client.
209
+ */
210
+ onExchange?(exchange: Exchange): void | Promise<void>;
195
211
  }
196
212
 
197
213
  /**
@@ -765,6 +781,20 @@ namespace Schmock {
765
781
  hasRoute?(method: HttpMethod, path: string): boolean;
766
782
  }
767
783
 
784
+ /**
785
+ * A relay transport's hold on in-page fetch interception, from
786
+ * `acquireFetchRelay()` in `@schmock/core/adapter`. While any hold is
787
+ * active, a fetch the page makes is forwarded to the network unanswered,
788
+ * with exactly the input and init it was called with, for the relay (a
789
+ * service worker) to bring back through `routeRelayedRequest()`.
790
+ */
791
+ interface FetchRelay {
792
+ /** Hand fetch back to in-page interception once no hold remains. Idempotent. */
793
+ release(): void;
794
+ /** False once released. */
795
+ readonly active: boolean;
796
+ }
797
+
768
798
  /** Input to `buildFormattedErrorResponse()`. */
769
799
  interface FormattedErrorOptions {
770
800
  /** The `errorFormatter` hook, called exactly once. */
@@ -824,6 +854,58 @@ namespace Schmock {
824
854
 
825
855
  type SchmockEvent = keyof SchmockEventMap;
826
856
 
857
+ // ===== Exchange Observation =====
858
+
859
+ /** The request half of an {@link Exchange}: the request as its client sent it, before any adapter `beforeRequest` hook. */
860
+ interface ExchangeRequest {
861
+ /** The method as the client sent it. */
862
+ readonly method: string;
863
+ /** The absolute request URL without its fragment, as `Response.url` reports it (a relative fetch is resolved against the document base). */
864
+ readonly url: string;
865
+ /** Request headers, names lowercased. */
866
+ readonly headers: Readonly<Record<string, string>>;
867
+ /** The body as the mock read it (JSON value, text, form fields, FormData, ArrayBuffer); absent when the request had none or it was never read. The observer's own copy. */
868
+ readonly body?: unknown;
869
+ }
870
+
871
+ /** The response half of an {@link AnsweredExchange}: what the client received. */
872
+ interface ExchangeResponse {
873
+ readonly status: number;
874
+ /** Headers of the Response the client received, names lowercased. */
875
+ readonly headers: Readonly<Record<string, string>>;
876
+ /** The body before serialization; absent for none (HEAD, 204). The observer's own copy. */
877
+ readonly body?: unknown;
878
+ }
879
+
880
+ interface ExchangeBase {
881
+ readonly request: ExchangeRequest;
882
+ /** `performance.now()` when the transport received the request. */
883
+ readonly startTime: number;
884
+ /** `performance.now()` when the client's outcome was settled. */
885
+ readonly endTime: number;
886
+ }
887
+
888
+ /** The mock answered: `response` is what the client got after `beforeResponse` and `errorFormatter`, including the 404 for an unrouted request and the 400 for a malformed JSON body when passthrough is off. */
889
+ interface AnsweredExchange extends ExchangeBase {
890
+ readonly outcome: "answered";
891
+ readonly response: ExchangeResponse;
892
+ }
893
+
894
+ /** The client's request rejected with `error` (a hook or handler threw and no errorFormatter replaced it, or the formatter threw). */
895
+ interface FailedExchange extends ExchangeBase {
896
+ readonly outcome: "failed";
897
+ /** Exactly the value the client's request rejected with. */
898
+ readonly error: unknown;
899
+ }
900
+
901
+ /** The client aborted the request while this mock was answering it. */
902
+ interface AbortedExchange extends ExchangeBase {
903
+ readonly outcome: "aborted";
904
+ }
905
+
906
+ /** One request a transport delivered to a mock, as its client saw it end. Passed to `Plugin.onExchange`. */
907
+ type Exchange = AnsweredExchange | FailedExchange | AbortedExchange;
908
+
827
909
  // ===== Introspection Types =====
828
910
 
829
911
  interface RouteInfo {
@@ -1225,14 +1307,14 @@ export type { HttpErrorReply, HttpIngressErrorCode, NodeRequestLike, NodeRespons
1225
1307
  export { collectBody, HttpIngressError, parseNodeHeaders, parseNodeQuery, serveNodeRequest, writeRejectedSchmockResponse, writeSchmockResponse, } from "./http-helpers.js";
1226
1308
  export { buildFormattedErrorResponse, normalizeResponse, serializeResponseBody, withDefaultContentType, } from "./response-normalizer.js";
1227
1309
  export { getResponseParts, replaceResponseBody, } from "./response-parser.js";
1228
- export type { AdapterRequest, AdapterRequestOverride, AdapterResponse,
1310
+ export type { AbortedExchange, AdapterRequest, AdapterRequestOverride, AdapterResponse,
1229
1311
  /**
1230
1312
  * @deprecated Import `AngularAdapterOptions` from `@schmock/angular`; this
1231
1313
  * copy will be removed in the next major version.
1232
1314
  */
1233
- AngularAdapterOptions, CallableMockInstance, CrudOperationMeta,
1315
+ AngularAdapterOptions, AnsweredExchange, CallableMockInstance, CrudOperationMeta, Exchange, ExchangeRequest, ExchangeResponse,
1234
1316
  /**
1235
1317
  * @deprecated Import `ExpressAdapterOptions` from `@schmock/express`; this
1236
1318
  * copy will be removed in the next major version.
1237
1319
  */
1238
- ExpressAdapterOptions, FakerPluginOptions, FormattedErrorOptions, Generator, GeneratorFunction, GlobalConfig, HttpMethod, InterceptHandle, InterceptOptions, OnSchemaCallback, OnSchemaContext, OpenApiCallbackOptions, OpenApiCallbackRequest, OpenApiOptions, OpenApiRefPolicy, PaginatedResponse, PaginateOptions, PathPrefix, Plugin, PluginContext, PluginHookResult, PluginResult, RequestContext, RequestEndEvent, RequestMatchEvent, RequestNotFoundEvent, RequestOptions, RequestRecord, RequestStartEvent, ResourceOverride, Response, ResponseBody, ResponseHeaderDef, ResponseParts, ResponseResult, RouteConfig, RouteInfo, RouteKey, Schema, SchemaDefinition, SchemaGenerationContext, SchmockEvent, SchmockEventMap, SeedConfig, SeedSource, ServerInfo, StaticData, } from "./types.js";
1320
+ ExpressAdapterOptions, FailedExchange, FakerPluginOptions, FormattedErrorOptions, Generator, GeneratorFunction, GlobalConfig, HttpMethod, InterceptHandle, InterceptOptions, OnSchemaCallback, OnSchemaContext, OpenApiCallbackOptions, OpenApiCallbackRequest, OpenApiOptions, OpenApiRefPolicy, PaginatedResponse, PaginateOptions, PathPrefix, Plugin, PluginContext, PluginHookResult, PluginResult, RequestContext, RequestEndEvent, RequestMatchEvent, RequestNotFoundEvent, RequestOptions, RequestRecord, RequestStartEvent, ResourceOverride, Response, ResponseBody, ResponseHeaderDef, ResponseParts, ResponseResult, RouteConfig, RouteInfo, RouteKey, Schema, SchemaDefinition, SchemaGenerationContext, SchmockEvent, SchmockEventMap, SeedConfig, SeedSource, ServerInfo, StaticData, } from "./types.js";
@@ -8,6 +8,23 @@
8
8
  * normalized again.
9
9
  */
10
10
  export declare const NORMALIZED_ADMISSION_KEY: unique symbol;
11
+ export type ExchangeObserver = (exchange: Schmock.Exchange) => void;
12
+ /** Called synchronously right before one consultation of a lease; undefined when nothing observes the lease's mock. */
13
+ type ExchangeObservationOpener = () => ExchangeObserver | undefined;
14
+ /**
15
+ * Makes every intercepted fetch skip routing and go straight to the baseline
16
+ * until released. Holds stack: fetches resume once every hold is released.
17
+ * It never touches `globalThis.fetch`.
18
+ */
19
+ export declare function acquireFetchRelay(): Schmock.FetchRelay;
20
+ /**
21
+ * Routes a request that a service worker relayed to the page through the
22
+ * newest session's leases. Resolves `undefined` when nothing answers it (no
23
+ * lease, or a route miss with passthrough) and never calls the baseline fetch,
24
+ * so the caller decides how the request reaches the network. Rejects with the
25
+ * request's abort reason when it is aborted mid-route.
26
+ */
27
+ export declare function routeRelayedRequest(request: Request): Promise<Response | undefined>;
11
28
  /**
12
29
  * Create a fetch interceptor that routes requests through mock.handle().
13
30
  *
@@ -17,3 +34,12 @@ export declare const NORMALIZED_ADMISSION_KEY: unique symbol;
17
34
  * handler — and emits its lifecycle events — once per request it is asked.
18
35
  */
19
36
  export declare function createFetchInterceptor(handle: Schmock.MockRequestHandler, options?: Schmock.InterceptOptions, admitRequest?: () => Schmock.RequestAdmission, owner?: symbol): Schmock.InterceptHandle;
37
+ interface FetchLeaseSpec {
38
+ handle: Schmock.MockRequestHandler;
39
+ options?: Schmock.InterceptOptions;
40
+ admitRequest?: () => Schmock.RequestAdmission;
41
+ owner?: symbol;
42
+ observe?: ExchangeObservationOpener;
43
+ }
44
+ export declare function createFetchLease(spec: FetchLeaseSpec): Schmock.InterceptHandle;
45
+ export {};
@@ -2,6 +2,7 @@
2
2
  import { awaitWithAbort, throwIfAborted } from "./abort.js";
3
3
  import { canonicalizePath, getResponseException, isHttpMethod, isRouteNotFound, matchPathPrefix, parsePathPrefix, } from "./constants.js";
4
4
  import { buildFormattedErrorResponse, buildJsonErrorResponse, normalizeResponse, serializeResponseBody, withDefaultContentType, } from "./response-normalizer.js";
5
+ import { snapshotRequestBody } from "./snapshot.js";
5
6
  const PASSTHROUGH = Symbol("schmock.fetch.passthrough");
6
7
  // A lease whose baseUrl filter rejected the request never reached its handler:
7
8
  // it was not interested in the request at all, and claimed nothing.
@@ -25,6 +26,11 @@ function isNormalizedAdmission(admission) {
25
26
  Reflect.get(admission, NORMALIZED_ADMISSION_KEY) === true);
26
27
  }
27
28
  let activeSession;
29
+ /**
30
+ * Holds taken through acquireFetchRelay(). Module-wide rather than per
31
+ * session, so a dispatcher that a third-party wrapper captured obeys them too.
32
+ */
33
+ const fetchRelayHolds = new Set();
28
34
  function getRelativeRequestBase() {
29
35
  const candidates = [
30
36
  typeof document === "undefined" ? undefined : document.baseURI,
@@ -111,58 +117,101 @@ function normalizeFetchRequest(input, init) {
111
117
  origin,
112
118
  };
113
119
  }
120
+ /**
121
+ * Whether `error` is the signal's own abort. An abort that lands after the
122
+ * lease already rejected with another error leaves that error the outcome.
123
+ */
124
+ function isAbortOf(signal, error) {
125
+ if (!signal.aborted)
126
+ return false;
127
+ if ("reason" in signal && signal.reason !== undefined) {
128
+ return error === signal.reason;
129
+ }
130
+ return error instanceof Error && error.name === "AbortError";
131
+ }
132
+ async function routeThroughLeases(leases, normalizedRequest, startTime) {
133
+ const { signal } = normalizedRequest.request;
134
+ throwIfAborted(signal);
135
+ // A mock is asked each distinct effective request at most once, however
136
+ // many leases it holds: without this, nested providers on one mock would
137
+ // run handle() — and emit request:start/notfound/end — once per lease.
138
+ // The key is the request each lease would issue after its own baseUrl
139
+ // filter and beforeRequest, so an older lease whose hook rewrites the
140
+ // request (an outer provider stripping "/api") still gets its turn.
141
+ const consultedRequests = new Map();
142
+ const claimFor = (owner) => (requestKey) => {
143
+ if (owner === undefined)
144
+ return true;
145
+ let keys = consultedRequests.get(owner);
146
+ if (keys === undefined) {
147
+ keys = new Set();
148
+ consultedRequests.set(owner, keys);
149
+ }
150
+ if (keys.has(requestKey))
151
+ return false;
152
+ keys.add(requestKey);
153
+ return true;
154
+ };
155
+ for (let index = leases.length - 1; index >= 0; index -= 1) {
156
+ const registered = leases[index];
157
+ // Synchronously before the call: the handler admits before its first await,
158
+ // so the generation the opener captures is the one the request runs in.
159
+ const observe = registered.observe?.();
160
+ const draft = { observed: observe !== undefined };
161
+ let result;
162
+ try {
163
+ result = await awaitWithAbort(registered.intercept({
164
+ request: normalizedRequest,
165
+ claim: claimFor(registered.owner),
166
+ draft,
167
+ }), signal);
168
+ throwIfAborted(signal);
169
+ }
170
+ catch (error) {
171
+ if (observe !== undefined) {
172
+ if (!isAbortOf(signal, error)) {
173
+ notify(observe, () => failedExchange(normalizedRequest, draft, error, startTime));
174
+ }
175
+ else if (draft.response !== undefined || draft.answers?.() === true) {
176
+ notify(observe, () => abortedExchange(normalizedRequest, draft, startTime));
177
+ }
178
+ }
179
+ throw error;
180
+ }
181
+ // FILTERED: this lease was not interested, so a sibling lease may be.
182
+ // ALREADY_CONSULTED: the mock already answered this exact request.
183
+ // PASSTHROUGH: the mock has no route for it. All three move on.
184
+ if (result === FILTERED ||
185
+ result === ALREADY_CONSULTED ||
186
+ result === PASSTHROUGH) {
187
+ continue;
188
+ }
189
+ const response = result;
190
+ if (observe !== undefined) {
191
+ notify(observe, () => answeredExchange(normalizedRequest, draft, response, startTime));
192
+ }
193
+ return response;
194
+ }
195
+ return PASSTHROUGH;
196
+ }
114
197
  function createInterceptorSession() {
115
198
  const baselineFetch = globalThis.fetch;
116
199
  const interceptors = [];
117
200
  const dispatchFetch = async (input, init) => {
118
201
  const snapshot = interceptors.slice();
119
- if (snapshot.length === 0) {
202
+ if (snapshot.length === 0 || fetchRelayHolds.size > 0) {
120
203
  return baselineFetch(input, init);
121
204
  }
205
+ const startTime = performance.now();
122
206
  const normalizedRequest = normalizeFetchRequest(input, init);
123
- throwIfAborted(normalizedRequest.request.signal);
124
- // A mock is asked each distinct effective request at most once, however
125
- // many leases it holds: without this, nested providers on one mock would
126
- // run handle() — and emit request:start/notfound/end — once per lease.
127
- // The key is the request each lease would issue after its own baseUrl
128
- // filter and beforeRequest, so an older lease whose hook rewrites the
129
- // request (an outer provider stripping "/api") still gets its turn.
130
- const consultedRequests = new Map();
131
- const claimFor = (owner) => (requestKey) => {
132
- if (owner === undefined)
133
- return true;
134
- let keys = consultedRequests.get(owner);
135
- if (keys === undefined) {
136
- keys = new Set();
137
- consultedRequests.set(owner, keys);
138
- }
139
- if (keys.has(requestKey))
140
- return false;
141
- keys.add(requestKey);
142
- return true;
143
- };
144
- for (let index = snapshot.length - 1; index >= 0; index -= 1) {
145
- const registered = snapshot[index];
146
- const result = await awaitWithAbort(registered.intercept({
147
- request: normalizedRequest,
148
- claim: claimFor(registered.owner),
149
- }), normalizedRequest.request.signal);
150
- throwIfAborted(normalizedRequest.request.signal);
151
- // FILTERED: this lease was not interested, so a sibling lease may be.
152
- // ALREADY_CONSULTED: the mock already answered this exact request.
153
- // PASSTHROUGH: the mock has no route for it. All three move on.
154
- if (result === FILTERED ||
155
- result === ALREADY_CONSULTED ||
156
- result === PASSTHROUGH) {
157
- continue;
158
- }
159
- return result;
160
- }
207
+ const answer = await routeThroughLeases(snapshot, normalizedRequest, startTime);
208
+ if (answer !== PASSTHROUGH)
209
+ return answer;
161
210
  return awaitWithAbort(baselineFetch(normalizedRequest.request), normalizedRequest.request.signal);
162
211
  };
163
212
  return { baselineFetch, dispatchFetch, interceptors };
164
213
  }
165
- function registerInterceptor(intercept, applyOptions, owner) {
214
+ function registerInterceptor(intercept, applyOptions, owner, observe) {
166
215
  let session = activeSession;
167
216
  if (!session || globalThis.fetch !== session.dispatchFetch) {
168
217
  session = createInterceptorSession();
@@ -170,7 +219,7 @@ function registerInterceptor(intercept, applyOptions, owner) {
170
219
  globalThis.fetch = session.dispatchFetch;
171
220
  }
172
221
  const token = Symbol("schmock.fetch.interceptor");
173
- session.interceptors.push({ token, owner, intercept });
222
+ session.interceptors.push({ token, owner, intercept, observe });
174
223
  let active = true;
175
224
  return {
176
225
  restore() {
@@ -204,15 +253,100 @@ function registerInterceptor(intercept, applyOptions, owner) {
204
253
  },
205
254
  };
206
255
  }
256
+ /**
257
+ * Makes every intercepted fetch skip routing and go straight to the baseline
258
+ * until released. Holds stack: fetches resume once every hold is released.
259
+ * It never touches `globalThis.fetch`.
260
+ */
261
+ export function acquireFetchRelay() {
262
+ const token = Symbol("schmock.fetch.relay");
263
+ fetchRelayHolds.add(token);
264
+ return {
265
+ release() {
266
+ fetchRelayHolds.delete(token);
267
+ },
268
+ get active() {
269
+ return fetchRelayHolds.has(token);
270
+ },
271
+ };
272
+ }
273
+ /**
274
+ * Routes a request that a service worker relayed to the page through the
275
+ * newest session's leases. Resolves `undefined` when nothing answers it (no
276
+ * lease, or a route miss with passthrough) and never calls the baseline fetch,
277
+ * so the caller decides how the request reaches the network. Rejects with the
278
+ * request's abort reason when it is aborted mid-route.
279
+ */
280
+ export async function routeRelayedRequest(request) {
281
+ const startTime = performance.now();
282
+ const leases = activeSession?.interceptors.slice() ?? [];
283
+ if (leases.length === 0)
284
+ return undefined;
285
+ const normalizedRequest = normalizeFetchRequest(request);
286
+ const answer = await routeThroughLeases(leases, normalizedRequest, startTime);
287
+ return answer === PASSTHROUGH ? undefined : answer;
288
+ }
207
289
  function extractQuery(url) {
208
290
  return Object.fromEntries(url.searchParams);
209
291
  }
210
- function extractHeaders(request) {
211
- const headers = {};
212
- request.headers.forEach((value, key) => {
213
- headers[key.toLowerCase()] = value;
292
+ function headerRecordOf(headers) {
293
+ const record = {};
294
+ headers.forEach((value, key) => {
295
+ record[key.toLowerCase()] = value;
214
296
  });
215
- return headers;
297
+ return record;
298
+ }
299
+ function extractHeaders(request) {
300
+ return headerRecordOf(request.headers);
301
+ }
302
+ /** Hand an exchange to its observer. */
303
+ function notify(observe, build) {
304
+ try {
305
+ observe(build());
306
+ }
307
+ catch {
308
+ // observation never changes the fetch outcome
309
+ }
310
+ }
311
+ function exchangeRequestOf({ request, url }, draft) {
312
+ return {
313
+ method: request.method,
314
+ url: responseUrlOf(url),
315
+ headers: extractHeaders(request),
316
+ ...(draft.requestBody !== undefined ? { body: draft.requestBody } : {}),
317
+ };
318
+ }
319
+ function answeredExchange(normalizedRequest, draft, response, startTime) {
320
+ return {
321
+ outcome: "answered",
322
+ request: exchangeRequestOf(normalizedRequest, draft),
323
+ response: {
324
+ status: response.status,
325
+ headers: headerRecordOf(response.headers),
326
+ ...(draft.response?.body !== undefined
327
+ ? { body: draft.response.body }
328
+ : {}),
329
+ },
330
+ startTime,
331
+ endTime: performance.now(),
332
+ };
333
+ }
334
+ function failedExchange(normalizedRequest, draft, error, startTime) {
335
+ return {
336
+ outcome: "failed",
337
+ request: exchangeRequestOf(normalizedRequest, draft),
338
+ error,
339
+ startTime,
340
+ endTime: performance.now(),
341
+ };
342
+ }
343
+ function abortedExchange(normalizedRequest, draft, startTime) {
344
+ return {
345
+ outcome: "aborted",
346
+ request: exchangeRequestOf(normalizedRequest, draft),
347
+ startTime,
348
+ endTime: performance.now(),
349
+ };
216
350
  }
217
351
  function normalizeMediaType(contentType) {
218
352
  return contentType?.split(";", 1)[0].trim().toLowerCase() ?? "";
@@ -253,7 +387,13 @@ async function extractNonJsonBody(request) {
253
387
  function routeProbeOf(admission) {
254
388
  if (admission === undefined)
255
389
  return undefined;
256
- const probe = Reflect.get(admission, "hasRoute");
390
+ let probe;
391
+ try {
392
+ probe = Reflect.get(admission, "hasRoute");
393
+ }
394
+ catch {
395
+ return undefined;
396
+ }
257
397
  if (typeof probe !== "function")
258
398
  return undefined;
259
399
  // Anything but a definite `false` counts as a route, so an unexpected
@@ -284,6 +424,7 @@ function createFetchResponse(normalized, context) {
284
424
  // A constructed Response has an empty url. Real fetch reports the request
285
425
  // URL, and code resolving links with `new URL(next, res.url)` needs it.
286
426
  Object.defineProperty(response, "url", { value: context.url });
427
+ context.draft.response = normalized;
287
428
  return response;
288
429
  }
289
430
  function toFetchResponse(response, context) {
@@ -344,12 +485,16 @@ function effectiveRequestKey(method, path) {
344
485
  * handler — and emits its lifecycle events — once per request it is asked.
345
486
  */
346
487
  export function createFetchInterceptor(handle, options = {}, admitRequest, owner) {
488
+ return createFetchLease({ handle, options, admitRequest, owner });
489
+ }
490
+ export function createFetchLease(spec) {
491
+ const { handle, admitRequest, owner, observe } = spec;
347
492
  // The options live in a mutable cell that each request reads once at its
348
493
  // start. Reconfiguring a lease in place is what lets an adapter apply new
349
494
  // hooks without re-registering — re-registration would move the lease to the
350
495
  // front of the dispatch order and steal precedence from other mocks.
351
- let currentOptions = options;
352
- return registerInterceptor(async ({ request: { request, url, origin }, claim, }) => {
496
+ let currentOptions = spec.options ?? {};
497
+ return registerInterceptor(async ({ request: { request, url, origin }, claim, draft, }) => {
353
498
  const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = currentOptions;
354
499
  const path = canonicalizePath(url.pathname);
355
500
  // BaseUrl filter — non-matching requests go straight to real fetch.
@@ -371,6 +516,7 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
371
516
  const context = {
372
517
  method: request.method,
373
518
  url: responseUrlOf(url),
519
+ draft,
374
520
  };
375
521
  const initialMethod = request.method.toUpperCase();
376
522
  // Without a beforeRequest hook (which may change the method or path)
@@ -399,7 +545,13 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
399
545
  // it. On a definite miss the body is never read: the request reaches
400
546
  // the network untouched, and handle() still runs, without a body, so
401
547
  // request:start/notfound/end are emitted exactly as before.
402
- const routeProbe = passthrough && !beforeRequest ? routeProbeOf(admission) : undefined;
548
+ const routeExists = routeProbeOf(admission);
549
+ const answersFor = (method, routedPath) => () => !passthrough ||
550
+ routeExists === undefined ||
551
+ routeExists(method, routedPath);
552
+ if (beforeRequest === undefined)
553
+ draft.answers = answersFor(initialMethod, path);
554
+ const routeProbe = passthrough && !beforeRequest ? routeExists : undefined;
403
555
  // The request handed to errorFormatter: the latest one this lease built,
404
556
  // so it reflects beforeRequest once that hook has returned.
405
557
  let formatterRequest;
@@ -407,6 +559,8 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
407
559
  const body = routeProbe !== undefined && !routeProbe(initialMethod, path)
408
560
  ? { value: undefined, malformedJson: false }
409
561
  : await awaitWithAbort(extractBody(request), request.signal);
562
+ if (draft.observed)
563
+ draft.requestBody = snapshotRequestBody(body.value);
410
564
  throwIfAborted(request.signal);
411
565
  // With passthrough off the lease owns every request that reaches it,
412
566
  // as the Node server does, so a JSON body that does not parse gets
@@ -449,6 +603,9 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
449
603
  !claim(effectiveRequestKey(effectiveMethod, adapterRequest.path))) {
450
604
  return ALREADY_CONSULTED;
451
605
  }
606
+ if (beforeRequest !== undefined) {
607
+ draft.answers = answersFor(effectiveMethod, adapterRequest.path);
608
+ }
452
609
  const requestOptions = {
453
610
  headers: adapterRequest.headers,
454
611
  body: adapterRequest.body,
@@ -531,5 +688,5 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
531
688
  }
532
689
  }, (nextOptions) => {
533
690
  currentOptions = nextOptions ?? {};
534
- }, owner);
691
+ }, owner, observe);
535
692
  }
@@ -2,10 +2,27 @@ import type { DebugLogger } from "./debug-logger.js";
2
2
  export declare function isThenable(value: unknown): value is PromiseLike<unknown>;
3
3
  /**
4
4
  * Reject, when it is piped, a plugin that could never work: one without a
5
- * `process` function answered every matched request with a 500 instead.
6
- * Only shapes that already failed are rejected, so no working setup breaks.
5
+ * `process` function (it answered every matched request with a 500), or one
6
+ * whose `install`, `beforeRequest` or `onExchange` is a truthy non-function.
7
+ * The `install` and `beforeRequest` shapes already failed before this check;
8
+ * `onExchange` is new, so no working setup breaks.
7
9
  */
8
10
  export declare function assertValidPlugin(plugin: unknown): asserts plugin is Schmock.Plugin;
11
+ /** Whether any plugin observes exchanges (a function `onExchange`). */
12
+ export declare function hasExchangeObserver(plugins: readonly Schmock.Plugin[]): boolean;
13
+ /**
14
+ * Report one settled exchange to each plugin's `onExchange`, in pipe order.
15
+ * Every observer gets its own frozen snapshot, built right before its call, so
16
+ * none sees or alters another's copy. A throwing or rejecting observer is
17
+ * logged and never stops the others. Reporting stops as soon as `isLive`
18
+ * returns false.
19
+ */
20
+ export declare function runExchangeHooks(input: {
21
+ plugins: readonly Schmock.Plugin[];
22
+ exchange: Schmock.Exchange;
23
+ logger: Pick<DebugLogger, "log">;
24
+ isLive?: () => boolean;
25
+ }): void;
9
26
  /** The live reads a hook's instance forwards to the mock. */
10
27
  export interface HookReadAccess {
11
28
  history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[];
@@ -1,4 +1,5 @@
1
1
  import { errorMessage, SchmockError } from "./errors.js";
2
+ import { snapshotNormalizedBody, snapshotRequestBody } from "./snapshot.js";
2
3
  const PLUGIN_HOOK_ERROR_CODES = {
3
4
  install: {
4
5
  expired: "PLUGIN_INSTALL_SCOPE_EXPIRED",
@@ -10,13 +11,16 @@ const PLUGIN_HOOK_ERROR_CODES = {
10
11
  },
11
12
  };
12
13
  /**
13
- * Optional hooks that break the plugin when set to a truthy non-function:
14
- * `install` threw a TypeError from pipe() and `beforeRequest` failed every
15
- * matched request. Falsy values (`onError: null`, `install: false`) are how
16
- * callers switch a hook off and keep working; `onError`/`uninstall` failures
17
- * only ever surfaced on paths that already fail or log, so they are left alone.
14
+ * Optional hooks rejected when set to a truthy non-function. `install` threw a
15
+ * TypeError from pipe() and `beforeRequest` failed every matched request, so
16
+ * rejecting them breaks no working setup. `onExchange` is new: no working
17
+ * setup relies on another shape, and a non-function would otherwise silently
18
+ * observe nothing. Falsy values (`onError: null`, `install: false`,
19
+ * `onExchange: undefined`) still switch a hook off. `onError`/`uninstall`
20
+ * failures only ever surfaced on paths that already fail or log, so they are
21
+ * left alone.
18
22
  */
19
- const EAGER_PLUGIN_HOOKS = ["install", "beforeRequest"];
23
+ const EAGER_PLUGIN_HOOKS = ["install", "beforeRequest", "onExchange"];
20
24
  export function isThenable(value) {
21
25
  return (typeof value === "object" &&
22
26
  value !== null &&
@@ -41,8 +45,10 @@ function describeInvalidPlugin(plugin) {
41
45
  }
42
46
  /**
43
47
  * Reject, when it is piped, a plugin that could never work: one without a
44
- * `process` function answered every matched request with a 500 instead.
45
- * Only shapes that already failed are rejected, so no working setup breaks.
48
+ * `process` function (it answered every matched request with a 500), or one
49
+ * whose `install`, `beforeRequest` or `onExchange` is a truthy non-function.
50
+ * The `install` and `beforeRequest` shapes already failed before this check;
51
+ * `onExchange` is new, so no working setup breaks.
46
52
  */
47
53
  export function assertValidPlugin(plugin) {
48
54
  const reason = describeInvalidPlugin(plugin);
@@ -57,6 +63,84 @@ export function assertValidPlugin(plugin) {
57
63
  reason,
58
64
  });
59
65
  }
66
+ /** Whether any plugin observes exchanges (a function `onExchange`). */
67
+ export function hasExchangeObserver(plugins) {
68
+ return plugins.some((plugin) => typeof plugin.onExchange === "function");
69
+ }
70
+ /** A fresh, frozen copy of an exchange, built for one observer. */
71
+ function snapshotExchange(exchange) {
72
+ const { request } = exchange;
73
+ const requestCopy = Object.freeze({
74
+ method: request.method,
75
+ url: request.url,
76
+ headers: Object.freeze({ ...request.headers }),
77
+ ...(request.body !== undefined
78
+ ? { body: snapshotRequestBody(request.body) }
79
+ : {}),
80
+ });
81
+ const { startTime, endTime } = exchange;
82
+ if (exchange.outcome === "answered") {
83
+ const { response } = exchange;
84
+ return Object.freeze({
85
+ outcome: exchange.outcome,
86
+ request: requestCopy,
87
+ response: Object.freeze({
88
+ status: response.status,
89
+ headers: Object.freeze({ ...response.headers }),
90
+ ...(response.body !== undefined
91
+ ? { body: snapshotNormalizedBody(response.body) }
92
+ : {}),
93
+ }),
94
+ startTime,
95
+ endTime,
96
+ });
97
+ }
98
+ if (exchange.outcome === "failed") {
99
+ return Object.freeze({
100
+ outcome: exchange.outcome,
101
+ request: requestCopy,
102
+ error: exchange.error,
103
+ startTime,
104
+ endTime,
105
+ });
106
+ }
107
+ return Object.freeze({
108
+ outcome: exchange.outcome,
109
+ request: requestCopy,
110
+ startTime,
111
+ endTime,
112
+ });
113
+ }
114
+ /**
115
+ * Report one settled exchange to each plugin's `onExchange`, in pipe order.
116
+ * Every observer gets its own frozen snapshot, built right before its call, so
117
+ * none sees or alters another's copy. A throwing or rejecting observer is
118
+ * logged and never stops the others. Reporting stops as soon as `isLive`
119
+ * returns false.
120
+ */
121
+ export function runExchangeHooks(input) {
122
+ const { plugins, exchange, logger, isLive } = input;
123
+ for (const plugin of plugins) {
124
+ if (isLive !== undefined && !isLive())
125
+ return;
126
+ try {
127
+ const observe = plugin.onExchange;
128
+ if (typeof observe !== "function")
129
+ continue;
130
+ const result = Reflect.apply(observe, plugin, [
131
+ snapshotExchange(exchange),
132
+ ]);
133
+ if (isThenable(result)) {
134
+ void Promise.resolve(result).catch((error) => {
135
+ logger.log("plugin", `Plugin ${plugin.name} onExchange rejected: ${errorMessage(error)}`);
136
+ });
137
+ }
138
+ }
139
+ catch (error) {
140
+ logger.log("plugin", `Plugin ${plugin.name} onExchange failed: ${errorMessage(error)}`);
141
+ }
142
+ }
143
+ }
60
144
  /**
61
145
  * The instance a plugin hook receives. Reads are live; route registration is
62
146
  * allowed only when the hook passes `registerRoute` (install does, uninstall
@@ -0,0 +1,14 @@
1
+ export declare function snapshotValue(value: unknown): unknown;
2
+ /**
3
+ * Snapshot a body that already went through `normalizeResponse`.
4
+ *
5
+ * A normalized body is a string, a `JSON.parse` tree or a fresh byte copy, so
6
+ * it can never hold shared memory: the `removeSharedMemory` walk that caller
7
+ * supplied values need would only re-visit every node for nothing.
8
+ */
9
+ export declare function snapshotNormalizedBody(value: unknown): unknown;
10
+ /**
11
+ * Snapshot a request body as a transport read it. A `FormData` is copied entry
12
+ * by entry, since `structuredClone` cannot copy one.
13
+ */
14
+ export declare function snapshotRequestBody(body: unknown): unknown;
@@ -0,0 +1,98 @@
1
+ function unavailableValue(value) {
2
+ let type = typeof value;
3
+ if (typeof value === "object" && value !== null) {
4
+ try {
5
+ type = Object.prototype.toString.call(value);
6
+ }
7
+ catch {
8
+ type = "object";
9
+ }
10
+ }
11
+ return {
12
+ kind: "unavailable",
13
+ reason: "not-structured-cloneable",
14
+ type,
15
+ };
16
+ }
17
+ function removeSharedMemory(value, seen = new WeakMap()) {
18
+ if (typeof value !== "object" || value === null)
19
+ return value;
20
+ const existing = seen.get(value);
21
+ if (existing !== undefined)
22
+ return existing;
23
+ if (typeof SharedArrayBuffer !== "undefined" &&
24
+ value instanceof SharedArrayBuffer) {
25
+ const copy = Uint8Array.from(new Uint8Array(value)).buffer;
26
+ seen.set(value, copy);
27
+ return copy;
28
+ }
29
+ if (ArrayBuffer.isView(value) &&
30
+ typeof SharedArrayBuffer !== "undefined" &&
31
+ value.buffer instanceof SharedArrayBuffer) {
32
+ const copy = Uint8Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
33
+ seen.set(value, copy);
34
+ return copy;
35
+ }
36
+ seen.set(value, value);
37
+ if (value instanceof Map) {
38
+ const entries = [...value.entries()];
39
+ value.clear();
40
+ for (const [key, entryValue] of entries) {
41
+ value.set(removeSharedMemory(key, seen), removeSharedMemory(entryValue, seen));
42
+ }
43
+ return value;
44
+ }
45
+ if (value instanceof Set) {
46
+ const entries = [...value.values()];
47
+ value.clear();
48
+ for (const entryValue of entries) {
49
+ value.add(removeSharedMemory(entryValue, seen));
50
+ }
51
+ return value;
52
+ }
53
+ for (const key of Reflect.ownKeys(value)) {
54
+ Reflect.set(value, key, removeSharedMemory(Reflect.get(value, key), seen));
55
+ }
56
+ return value;
57
+ }
58
+ export function snapshotValue(value) {
59
+ try {
60
+ return removeSharedMemory(structuredClone(value));
61
+ }
62
+ catch {
63
+ return unavailableValue(value);
64
+ }
65
+ }
66
+ /**
67
+ * Snapshot a body that already went through `normalizeResponse`.
68
+ *
69
+ * A normalized body is a string, a `JSON.parse` tree or a fresh byte copy, so
70
+ * it can never hold shared memory: the `removeSharedMemory` walk that caller
71
+ * supplied values need would only re-visit every node for nothing.
72
+ */
73
+ export function snapshotNormalizedBody(value) {
74
+ try {
75
+ return structuredClone(value);
76
+ }
77
+ catch {
78
+ return unavailableValue(value);
79
+ }
80
+ }
81
+ /**
82
+ * Snapshot a request body as a transport read it. A `FormData` is copied entry
83
+ * by entry, since `structuredClone` cannot copy one.
84
+ */
85
+ export function snapshotRequestBody(body) {
86
+ if (typeof FormData !== "undefined" && body instanceof FormData) {
87
+ try {
88
+ const copy = new FormData();
89
+ for (const [key, value] of body.entries())
90
+ copy.append(key, value);
91
+ return copy;
92
+ }
93
+ catch {
94
+ return unavailableValue(body);
95
+ }
96
+ }
97
+ return snapshotValue(body);
98
+ }
package/dist/types.d.ts CHANGED
@@ -63,3 +63,10 @@ export type PathPrefix = Schmock.PathPrefix;
63
63
  export type FormattedErrorOptions = Schmock.FormattedErrorOptions;
64
64
  export type MockRequestHandler = Schmock.MockRequestHandler;
65
65
  export type RequestAdmission = Schmock.RequestAdmission;
66
+ export type FetchRelay = Schmock.FetchRelay;
67
+ export type Exchange = Schmock.Exchange;
68
+ export type ExchangeRequest = Schmock.ExchangeRequest;
69
+ export type ExchangeResponse = Schmock.ExchangeResponse;
70
+ export type AnsweredExchange = Schmock.AnsweredExchange;
71
+ export type FailedExchange = Schmock.FailedExchange;
72
+ export type AbortedExchange = Schmock.AbortedExchange;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/core",
3
3
  "description": "Core functionality for Schmock",
4
- "version": "2.5.0",
4
+ "version": "2.6.0",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",