@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 +4 -2
- package/dist/adapter.js +3 -1
- package/dist/builder.js +30 -3
- package/dist/generations.d.ts +5 -0
- package/dist/generations.js +7 -0
- package/dist/history.js +3 -82
- package/dist/index.d.ts +85 -3
- package/dist/interceptor.d.ts +26 -0
- package/dist/interceptor.js +208 -51
- package/dist/plugin-hooks.d.ts +19 -2
- package/dist/plugin-hooks.js +92 -8
- package/dist/snapshot.d.ts +14 -0
- package/dist/snapshot.js +98 -0
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
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 {
|
|
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 =
|
|
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();
|
package/dist/generations.d.ts
CHANGED
|
@@ -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
|
/**
|
package/dist/generations.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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:
|
|
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";
|
package/dist/interceptor.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/interceptor.js
CHANGED
|
@@ -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
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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
|
|
211
|
-
const
|
|
212
|
-
|
|
213
|
-
|
|
292
|
+
function headerRecordOf(headers) {
|
|
293
|
+
const record = {};
|
|
294
|
+
headers.forEach((value, key) => {
|
|
295
|
+
record[key.toLowerCase()] = value;
|
|
214
296
|
});
|
|
215
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
}
|
package/dist/plugin-hooks.d.ts
CHANGED
|
@@ -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
|
|
6
|
-
*
|
|
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[];
|
package/dist/plugin-hooks.js
CHANGED
|
@@ -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
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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
|
|
45
|
-
*
|
|
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;
|
package/dist/snapshot.js
ADDED
|
@@ -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;
|