@schmock/core 2.4.1 → 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/README.md +129 -0
- package/dist/abort.d.ts +11 -1
- package/dist/abort.js +13 -2
- package/dist/adapter.d.ts +21 -0
- package/dist/adapter.js +19 -0
- package/dist/admission.d.ts +21 -0
- package/dist/admission.js +39 -0
- package/dist/binary.d.ts +0 -1
- package/dist/builder.d.ts +16 -32
- package/dist/builder.js +425 -904
- package/dist/constants.d.ts +33 -2
- package/dist/constants.js +73 -1
- package/dist/debug-logger.d.ts +10 -0
- package/dist/debug-logger.js +31 -0
- package/dist/delay.d.ts +12 -0
- package/dist/delay.js +37 -0
- package/dist/errors.d.ts +13 -2
- package/dist/errors.js +21 -2
- package/dist/events.d.ts +17 -0
- package/dist/events.js +58 -0
- package/dist/generations.d.ts +48 -0
- package/dist/generations.js +82 -0
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +57 -0
- package/dist/helpers.d.ts +9 -10
- package/dist/helpers.js +4 -1
- package/dist/history.d.ts +56 -0
- package/dist/history.js +151 -0
- package/dist/http-helpers.d.ts +110 -5
- package/dist/http-helpers.js +328 -46
- package/dist/index.d.ts +295 -31
- package/dist/index.js +17 -9
- package/dist/interceptor.d.ts +40 -10
- package/dist/interceptor.js +412 -178
- package/dist/node-server.d.ts +27 -0
- package/dist/node-server.js +166 -0
- package/dist/parser.d.ts +0 -1
- package/dist/parser.js +145 -22
- package/dist/plugin-hooks.d.ts +57 -0
- package/dist/plugin-hooks.js +276 -0
- package/dist/plugin-pipeline.d.ts +0 -1
- package/dist/plugin-pipeline.js +25 -4
- package/dist/response-normalizer.d.ts +36 -1
- package/dist/response-normalizer.js +102 -0
- package/dist/response-parser.d.ts +19 -1
- package/dist/response-parser.js +77 -19
- package/dist/route-matcher.d.ts +0 -1
- package/dist/route-table.d.ts +64 -0
- package/dist/route-table.js +220 -0
- package/dist/snapshot.d.ts +14 -0
- package/dist/snapshot.js +98 -0
- package/dist/types.d.ts +34 -1
- package/package.json +8 -3
- package/dist/abort.d.ts.map +0 -1
- package/dist/binary.d.ts.map +0 -1
- package/dist/builder.d.ts.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/helpers.d.ts.map +0 -1
- package/dist/http-helpers.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/interceptor.d.ts.map +0 -1
- package/dist/parser.d.ts.map +0 -1
- package/dist/plugin-pipeline.d.ts.map +0 -1
- package/dist/response-normalizer.d.ts.map +0 -1
- package/dist/response-parser.d.ts.map +0 -1
- package/dist/route-matcher.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
package/dist/interceptor.js
CHANGED
|
@@ -1,15 +1,36 @@
|
|
|
1
1
|
/// <reference path="../schmock.d.ts" />
|
|
2
2
|
import { awaitWithAbort, throwIfAborted } from "./abort.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { canonicalizePath, getResponseException, isHttpMethod, isRouteNotFound, matchPathPrefix, parsePathPrefix, } from "./constants.js";
|
|
4
|
+
import { buildFormattedErrorResponse, buildJsonErrorResponse, normalizeResponse, serializeResponseBody, withDefaultContentType, } from "./response-normalizer.js";
|
|
5
|
+
import { snapshotRequestBody } from "./snapshot.js";
|
|
6
6
|
const PASSTHROUGH = Symbol("schmock.fetch.passthrough");
|
|
7
|
-
// A lease whose baseUrl filter rejected the request never reached its handler
|
|
8
|
-
//
|
|
9
|
-
// already ran" from "this lease was not interested in the request at all".
|
|
7
|
+
// A lease whose baseUrl filter rejected the request never reached its handler:
|
|
8
|
+
// it was not interested in the request at all, and claimed nothing.
|
|
10
9
|
const FILTERED = Symbol("schmock.fetch.filtered");
|
|
10
|
+
// A newer lease of the same mock already asked it this exact request, so
|
|
11
|
+
// asking again would only repeat the answer and its lifecycle events.
|
|
12
|
+
const ALREADY_CONSULTED = Symbol("schmock.fetch.already-consulted");
|
|
11
13
|
const RELATIVE_REQUEST_BASE = "http://schmock.invalid/";
|
|
14
|
+
/**
|
|
15
|
+
* Marks an admission whose `handle()` already returns responses normalized
|
|
16
|
+
* for the request method (hop-by-hop headers dropped, a HEAD body stripped):
|
|
17
|
+
* only the admissions a `schmock()` instance of this copy creates. The
|
|
18
|
+
* interceptor re-normalizes the responses of any other admission, such as a
|
|
19
|
+
* hand-written one passed to `createFetchInterceptor`. Deliberately
|
|
20
|
+
* unregistered: an admission from a second copy of `@schmock/core` is simply
|
|
21
|
+
* normalized again.
|
|
22
|
+
*/
|
|
23
|
+
export const NORMALIZED_ADMISSION_KEY = Symbol("schmock.normalized-admission");
|
|
24
|
+
function isNormalizedAdmission(admission) {
|
|
25
|
+
return (admission !== undefined &&
|
|
26
|
+
Reflect.get(admission, NORMALIZED_ADMISSION_KEY) === true);
|
|
27
|
+
}
|
|
12
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();
|
|
13
34
|
function getRelativeRequestBase() {
|
|
14
35
|
const candidates = [
|
|
15
36
|
typeof document === "undefined" ? undefined : document.baseURI,
|
|
@@ -96,45 +117,101 @@ function normalizeFetchRequest(input, init) {
|
|
|
96
117
|
origin,
|
|
97
118
|
};
|
|
98
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
|
+
}
|
|
99
197
|
function createInterceptorSession() {
|
|
100
198
|
const baselineFetch = globalThis.fetch;
|
|
101
199
|
const interceptors = [];
|
|
102
200
|
const dispatchFetch = async (input, init) => {
|
|
103
201
|
const snapshot = interceptors.slice();
|
|
104
|
-
if (snapshot.length === 0) {
|
|
202
|
+
if (snapshot.length === 0 || fetchRelayHolds.size > 0) {
|
|
105
203
|
return baselineFetch(input, init);
|
|
106
204
|
}
|
|
205
|
+
const startTime = performance.now();
|
|
107
206
|
const normalizedRequest = normalizeFetchRequest(input, init);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// — and emit request:start/notfound/end — once per lease.
|
|
112
|
-
const consultedOwners = new Set();
|
|
113
|
-
for (let index = snapshot.length - 1; index >= 0; index -= 1) {
|
|
114
|
-
const registered = snapshot[index];
|
|
115
|
-
const { owner } = registered;
|
|
116
|
-
if (owner !== undefined && consultedOwners.has(owner)) {
|
|
117
|
-
continue;
|
|
118
|
-
}
|
|
119
|
-
const result = await awaitWithAbort(registered.intercept(normalizedRequest), normalizedRequest.request.signal);
|
|
120
|
-
throwIfAborted(normalizedRequest.request.signal);
|
|
121
|
-
// The lease filtered the request out before admission, so its owner
|
|
122
|
-
// keeps its turn — a sibling lease may carry a matching baseUrl.
|
|
123
|
-
if (result === FILTERED) {
|
|
124
|
-
continue;
|
|
125
|
-
}
|
|
126
|
-
if (result !== PASSTHROUGH) {
|
|
127
|
-
return result;
|
|
128
|
-
}
|
|
129
|
-
if (owner !== undefined) {
|
|
130
|
-
consultedOwners.add(owner);
|
|
131
|
-
}
|
|
132
|
-
}
|
|
207
|
+
const answer = await routeThroughLeases(snapshot, normalizedRequest, startTime);
|
|
208
|
+
if (answer !== PASSTHROUGH)
|
|
209
|
+
return answer;
|
|
133
210
|
return awaitWithAbort(baselineFetch(normalizedRequest.request), normalizedRequest.request.signal);
|
|
134
211
|
};
|
|
135
212
|
return { baselineFetch, dispatchFetch, interceptors };
|
|
136
213
|
}
|
|
137
|
-
function registerInterceptor(intercept, applyOptions, owner) {
|
|
214
|
+
function registerInterceptor(intercept, applyOptions, owner, observe) {
|
|
138
215
|
let session = activeSession;
|
|
139
216
|
if (!session || globalThis.fetch !== session.dispatchFetch) {
|
|
140
217
|
session = createInterceptorSession();
|
|
@@ -142,7 +219,7 @@ function registerInterceptor(intercept, applyOptions, owner) {
|
|
|
142
219
|
globalThis.fetch = session.dispatchFetch;
|
|
143
220
|
}
|
|
144
221
|
const token = Symbol("schmock.fetch.interceptor");
|
|
145
|
-
session.interceptors.push({ token, owner, intercept });
|
|
222
|
+
session.interceptors.push({ token, owner, intercept, observe });
|
|
146
223
|
let active = true;
|
|
147
224
|
return {
|
|
148
225
|
restore() {
|
|
@@ -177,57 +254,124 @@ function registerInterceptor(intercept, applyOptions, owner) {
|
|
|
177
254
|
};
|
|
178
255
|
}
|
|
179
256
|
/**
|
|
180
|
-
*
|
|
181
|
-
*
|
|
182
|
-
*
|
|
183
|
-
* - "https://x.com" → { origin: "https://x.com", path: "" }
|
|
184
|
-
*
|
|
185
|
-
* Trailing slash is stripped from the path so the segment-boundary check
|
|
186
|
-
* works the same way for "/api" and "/api/".
|
|
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`.
|
|
187
260
|
*/
|
|
188
|
-
function
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
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;
|
|
203
288
|
}
|
|
204
289
|
function extractQuery(url) {
|
|
205
290
|
return Object.fromEntries(url.searchParams);
|
|
206
291
|
}
|
|
207
|
-
function
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
292
|
+
function headerRecordOf(headers) {
|
|
293
|
+
const record = {};
|
|
294
|
+
headers.forEach((value, key) => {
|
|
295
|
+
record[key.toLowerCase()] = value;
|
|
211
296
|
});
|
|
212
|
-
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
|
+
};
|
|
213
350
|
}
|
|
214
351
|
function normalizeMediaType(contentType) {
|
|
215
352
|
return contentType?.split(";", 1)[0].trim().toLowerCase() ?? "";
|
|
216
353
|
}
|
|
217
354
|
async function extractBody(request) {
|
|
218
355
|
if (request.body === null)
|
|
219
|
-
return undefined;
|
|
220
|
-
const body = request.clone();
|
|
356
|
+
return { value: undefined, malformedJson: false };
|
|
221
357
|
const mediaType = normalizeMediaType(request.headers.get("content-type"));
|
|
222
|
-
if (mediaType
|
|
223
|
-
|
|
224
|
-
try {
|
|
225
|
-
return JSON.parse(text);
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
return text;
|
|
229
|
-
}
|
|
358
|
+
if (mediaType !== "application/json" && !mediaType.endsWith("+json")) {
|
|
359
|
+
return { value: await extractNonJsonBody(request), malformedJson: false };
|
|
230
360
|
}
|
|
361
|
+
const text = await request.clone().text();
|
|
362
|
+
// An empty JSON body is no body at all, as the Node ingress reads it.
|
|
363
|
+
if (text === "")
|
|
364
|
+
return { value: undefined, malformedJson: false };
|
|
365
|
+
try {
|
|
366
|
+
return { value: JSON.parse(text), malformedJson: false };
|
|
367
|
+
}
|
|
368
|
+
catch {
|
|
369
|
+
return { value: text, malformedJson: true };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
async function extractNonJsonBody(request) {
|
|
373
|
+
const body = request.clone();
|
|
374
|
+
const mediaType = normalizeMediaType(request.headers.get("content-type"));
|
|
231
375
|
if (mediaType === "application/x-www-form-urlencoded") {
|
|
232
376
|
return Object.fromEntries(new URLSearchParams(await body.text()));
|
|
233
377
|
}
|
|
@@ -239,106 +383,118 @@ async function extractBody(request) {
|
|
|
239
383
|
}
|
|
240
384
|
return body.arrayBuffer();
|
|
241
385
|
}
|
|
386
|
+
/** An admission's route probe (`hasRoute`), when it carries one. */
|
|
387
|
+
function routeProbeOf(admission) {
|
|
388
|
+
if (admission === undefined)
|
|
389
|
+
return undefined;
|
|
390
|
+
let probe;
|
|
391
|
+
try {
|
|
392
|
+
probe = Reflect.get(admission, "hasRoute");
|
|
393
|
+
}
|
|
394
|
+
catch {
|
|
395
|
+
return undefined;
|
|
396
|
+
}
|
|
397
|
+
if (typeof probe !== "function")
|
|
398
|
+
return undefined;
|
|
399
|
+
// Anything but a definite `false` counts as a route, so an unexpected
|
|
400
|
+
// answer, or a throw, only costs the body read the probe would have saved.
|
|
401
|
+
return (method, path) => {
|
|
402
|
+
try {
|
|
403
|
+
return Reflect.apply(probe, admission, [method, path]) !== false;
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
return true;
|
|
407
|
+
}
|
|
408
|
+
};
|
|
409
|
+
}
|
|
242
410
|
function isAbortError(error) {
|
|
243
411
|
return (typeof error === "object" &&
|
|
244
412
|
error !== null &&
|
|
245
413
|
"name" in error &&
|
|
246
414
|
error.name === "AbortError");
|
|
247
415
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
response.body !== undefined) {
|
|
254
|
-
if (isBinaryBody(response.body)) {
|
|
255
|
-
headers["content-type"] = "application/octet-stream";
|
|
256
|
-
}
|
|
257
|
-
else if (typeof response.body !== "string") {
|
|
258
|
-
headers["content-type"] = "application/json";
|
|
259
|
-
}
|
|
260
|
-
}
|
|
261
|
-
const normalized = normalizeResponse({ ...response, headers }, method);
|
|
262
|
-
return new Response(serializeResponseBody(normalized) ?? null, {
|
|
416
|
+
/**
|
|
417
|
+
* Build the fetch Response from an already-normalized Schmock response.
|
|
418
|
+
*/
|
|
419
|
+
function createFetchResponse(normalized, context) {
|
|
420
|
+
const response = new Response(serializeResponseBody(normalized) ?? null, {
|
|
263
421
|
status: normalized.status,
|
|
264
422
|
headers: normalized.headers,
|
|
265
423
|
});
|
|
424
|
+
// A constructed Response has an empty url. Real fetch reports the request
|
|
425
|
+
// URL, and code resolving links with `new URL(next, res.url)` needs it.
|
|
426
|
+
Object.defineProperty(response, "url", { value: context.url });
|
|
427
|
+
context.draft.response = normalized;
|
|
428
|
+
return response;
|
|
429
|
+
}
|
|
430
|
+
function toFetchResponse(response, context) {
|
|
431
|
+
return createFetchResponse(normalizeResponse(withDefaultContentType(response), context.method), context);
|
|
432
|
+
}
|
|
433
|
+
function jsonErrorResponse(input) {
|
|
434
|
+
return createFetchResponse(buildJsonErrorResponse({
|
|
435
|
+
status: input.status,
|
|
436
|
+
error: input.error,
|
|
437
|
+
code: input.code,
|
|
438
|
+
method: input.context.method,
|
|
439
|
+
}), input.context);
|
|
266
440
|
}
|
|
267
441
|
/**
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* first: leaving a `Content-Type` beside the forced lowercase key makes the
|
|
271
|
-
* pair transport-invalid and the normalizer rejects it.
|
|
442
|
+
* A request this lease owns but cannot route: pass it on, or answer the same
|
|
443
|
+
* 404 a route miss gets when passthrough is off.
|
|
272
444
|
*/
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
445
|
+
function unroutedResult(passthrough, context) {
|
|
446
|
+
if (passthrough)
|
|
447
|
+
return PASSTHROUGH;
|
|
448
|
+
return jsonErrorResponse({
|
|
449
|
+
status: 404,
|
|
450
|
+
error: "No matching mock route found",
|
|
451
|
+
code: "ROUTE_NOT_FOUND",
|
|
452
|
+
context,
|
|
453
|
+
});
|
|
282
454
|
}
|
|
283
455
|
/**
|
|
284
|
-
*
|
|
285
|
-
*
|
|
286
|
-
*
|
|
287
|
-
*
|
|
288
|
-
*
|
|
289
|
-
* `try`, so an escaping error would land in the catch below and invoke the
|
|
290
|
-
* formatter a second time; the re-entrancy is exactly the defect the Express
|
|
291
|
-
* adapter's `sendFormattedError` was shaped to avoid.
|
|
292
|
-
*
|
|
293
|
-
* `responseHeaders` carries the (post-hook) headers of the response being
|
|
294
|
-
* replaced so metadata such as `retry-after` survives. There are two distinct
|
|
295
|
-
* fallbacks. When the inherited headers are untransportable, the send is
|
|
296
|
-
* retried once with the fixed JSON header set and the SAME formatted body —
|
|
297
|
-
* nothing is on the wire yet, and losing the body would silently change the
|
|
298
|
-
* user's error contract. Only a failure of the formatter itself, or of its
|
|
299
|
-
* body, reaches the minimal fallback, which deliberately inherits nothing.
|
|
456
|
+
* The fetch Response for an errorFormatter result, built by the shared
|
|
457
|
+
* {@link buildFormattedErrorResponse}. TOTAL: it never throws, and the
|
|
458
|
+
* formatter runs exactly once. It runs inside the interceptor's `try`, so an
|
|
459
|
+
* escaping error would land in the catch below and invoke the formatter a
|
|
460
|
+
* second time.
|
|
300
461
|
*/
|
|
301
|
-
function
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
}, method);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
catch {
|
|
321
|
-
return toFetchResponse({
|
|
322
|
-
status: 500,
|
|
323
|
-
body: { error: "Internal Server Error", code: "INTERNAL_ERROR" },
|
|
324
|
-
headers: { "content-type": "application/json" },
|
|
325
|
-
}, method);
|
|
326
|
-
}
|
|
462
|
+
function formattedErrorResponse(input) {
|
|
463
|
+
return createFetchResponse(buildFormattedErrorResponse({
|
|
464
|
+
formatter: input.formatter,
|
|
465
|
+
error: input.error,
|
|
466
|
+
inheritedHeaders: input.responseHeaders,
|
|
467
|
+
method: input.context.method,
|
|
468
|
+
}), input.context);
|
|
469
|
+
}
|
|
470
|
+
/** Fetch reports the request URL without its fragment. */
|
|
471
|
+
function responseUrlOf(url) {
|
|
472
|
+
const responseUrl = new URL(url.href);
|
|
473
|
+
responseUrl.hash = "";
|
|
474
|
+
return responseUrl.href;
|
|
475
|
+
}
|
|
476
|
+
function effectiveRequestKey(method, path) {
|
|
477
|
+
return `${method} ${canonicalizePath(path)}`;
|
|
327
478
|
}
|
|
328
479
|
/**
|
|
329
480
|
* Create a fetch interceptor that routes requests through mock.handle().
|
|
330
481
|
*
|
|
331
|
-
* `owner` identifies the mock behind the lease. Leases sharing an owner
|
|
332
|
-
*
|
|
333
|
-
*
|
|
482
|
+
* `owner` identifies the mock behind the lease. Leases sharing an owner ask it
|
|
483
|
+
* each distinct effective request (method and path after the lease's own
|
|
484
|
+
* beforeRequest) at most once, so a mock held by several leases runs its
|
|
485
|
+
* handler — and emits its lifecycle events — once per request it is asked.
|
|
334
486
|
*/
|
|
335
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;
|
|
336
492
|
// The options live in a mutable cell that each request reads once at its
|
|
337
493
|
// start. Reconfiguring a lease in place is what lets an adapter apply new
|
|
338
494
|
// hooks without re-registering — re-registration would move the lease to the
|
|
339
495
|
// front of the dispatch order and steal precedence from other mocks.
|
|
340
|
-
let currentOptions = options;
|
|
341
|
-
return registerInterceptor(async ({ request, url, origin }) => {
|
|
496
|
+
let currentOptions = spec.options ?? {};
|
|
497
|
+
return registerInterceptor(async ({ request: { request, url, origin }, claim, draft, }) => {
|
|
342
498
|
const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = currentOptions;
|
|
343
499
|
const path = canonicalizePath(url.pathname);
|
|
344
500
|
// BaseUrl filter — non-matching requests go straight to real fetch.
|
|
@@ -348,32 +504,83 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
348
504
|
// - path form ("/api"): match pathname prefix only.
|
|
349
505
|
// Both enforce a segment boundary so "/api" doesn't match "/apiv2".
|
|
350
506
|
if (baseUrl) {
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
507
|
+
const base = parsePathPrefix(baseUrl);
|
|
508
|
+
if (base.origin && origin !== base.origin) {
|
|
353
509
|
return FILTERED;
|
|
354
510
|
}
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
if (!isMatch) {
|
|
358
|
-
return FILTERED;
|
|
359
|
-
}
|
|
511
|
+
if (!matchPathPrefix(base, path)) {
|
|
512
|
+
return FILTERED;
|
|
360
513
|
}
|
|
361
514
|
}
|
|
362
515
|
throwIfAborted(request.signal);
|
|
363
|
-
const
|
|
516
|
+
const context = {
|
|
517
|
+
method: request.method,
|
|
518
|
+
url: responseUrlOf(url),
|
|
519
|
+
draft,
|
|
520
|
+
};
|
|
521
|
+
const initialMethod = request.method.toUpperCase();
|
|
522
|
+
// Without a beforeRequest hook (which may change the method or path)
|
|
523
|
+
// the effective request is already known, so it is claimed before this
|
|
524
|
+
// lease can answer anything: once a newer lease of the same mock has
|
|
525
|
+
// passed it through, an older passthrough:false lease must not answer
|
|
526
|
+
// it with a 404 or a malformed-JSON 400. A lease with a hook claims only
|
|
527
|
+
// after the hook, so its own 400 for a malformed JSON body (which comes
|
|
528
|
+
// before the hook) still answers.
|
|
529
|
+
if (beforeRequest === undefined &&
|
|
530
|
+
!claim(effectiveRequestKey(initialMethod, path))) {
|
|
531
|
+
return ALREADY_CONSULTED;
|
|
532
|
+
}
|
|
533
|
+
// No route can ever match a method outside the supported set (WebDAV's
|
|
534
|
+
// PROPFIND, a CDN PURGE), so it is a miss like any other rather than a
|
|
535
|
+
// rejected fetch. Checked before admission, which it never needs.
|
|
536
|
+
if (!isHttpMethod(initialMethod)) {
|
|
537
|
+
return unroutedResult(passthrough, context);
|
|
538
|
+
}
|
|
539
|
+
context.method = initialMethod;
|
|
364
540
|
const admission = admitRequest?.();
|
|
365
541
|
const admittedHandle = admission?.handle ?? handle;
|
|
366
|
-
|
|
542
|
+
// Without a beforeRequest hook (which receives the body and may change
|
|
543
|
+
// the method or path), the request handle() will see is already known,
|
|
544
|
+
// so a passthrough lease asks the admission whether any route answers
|
|
545
|
+
// it. On a definite miss the body is never read: the request reaches
|
|
546
|
+
// the network untouched, and handle() still runs, without a body, so
|
|
547
|
+
// request:start/notfound/end are emitted exactly as before.
|
|
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;
|
|
555
|
+
// The request handed to errorFormatter: the latest one this lease built,
|
|
556
|
+
// so it reflects beforeRequest once that hook has returned.
|
|
557
|
+
let formatterRequest;
|
|
367
558
|
try {
|
|
368
|
-
const body =
|
|
559
|
+
const body = routeProbe !== undefined && !routeProbe(initialMethod, path)
|
|
560
|
+
? { value: undefined, malformedJson: false }
|
|
561
|
+
: await awaitWithAbort(extractBody(request), request.signal);
|
|
562
|
+
if (draft.observed)
|
|
563
|
+
draft.requestBody = snapshotRequestBody(body.value);
|
|
369
564
|
throwIfAborted(request.signal);
|
|
565
|
+
// With passthrough off the lease owns every request that reaches it,
|
|
566
|
+
// as the Node server does, so a JSON body that does not parse gets
|
|
567
|
+
// the server's 400 before any route runs or history records it.
|
|
568
|
+
if (body.malformedJson && !passthrough) {
|
|
569
|
+
return jsonErrorResponse({
|
|
570
|
+
status: 400,
|
|
571
|
+
error: "Malformed JSON request body",
|
|
572
|
+
code: "MALFORMED_JSON",
|
|
573
|
+
context,
|
|
574
|
+
});
|
|
575
|
+
}
|
|
370
576
|
let adapterRequest = {
|
|
371
577
|
method: request.method,
|
|
372
578
|
path,
|
|
373
579
|
headers: extractHeaders(request),
|
|
374
|
-
body,
|
|
580
|
+
body: body.value,
|
|
375
581
|
query: extractQuery(url),
|
|
376
582
|
};
|
|
583
|
+
formatterRequest = adapterRequest;
|
|
377
584
|
// Apply beforeRequest hook
|
|
378
585
|
if (beforeRequest) {
|
|
379
586
|
throwIfAborted(request.signal);
|
|
@@ -381,16 +588,30 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
381
588
|
throwIfAborted(request.signal);
|
|
382
589
|
if (modified) {
|
|
383
590
|
adapterRequest = modified;
|
|
591
|
+
formatterRequest = modified;
|
|
384
592
|
}
|
|
385
593
|
}
|
|
386
594
|
throwIfAborted(request.signal);
|
|
595
|
+
// A hook may produce a method no route can match; that is a miss
|
|
596
|
+
// too, as the Angular adapter treats it.
|
|
597
|
+
const effectiveMethod = adapterRequest.method.toUpperCase();
|
|
598
|
+
if (!isHttpMethod(effectiveMethod)) {
|
|
599
|
+
return unroutedResult(passthrough, context);
|
|
600
|
+
}
|
|
601
|
+
context.method = effectiveMethod;
|
|
602
|
+
if (beforeRequest !== undefined &&
|
|
603
|
+
!claim(effectiveRequestKey(effectiveMethod, adapterRequest.path))) {
|
|
604
|
+
return ALREADY_CONSULTED;
|
|
605
|
+
}
|
|
606
|
+
if (beforeRequest !== undefined) {
|
|
607
|
+
draft.answers = answersFor(effectiveMethod, adapterRequest.path);
|
|
608
|
+
}
|
|
387
609
|
const requestOptions = {
|
|
388
610
|
headers: adapterRequest.headers,
|
|
389
611
|
body: adapterRequest.body,
|
|
390
612
|
query: adapterRequest.query,
|
|
391
613
|
signal: request.signal,
|
|
392
614
|
};
|
|
393
|
-
effectiveMethod = toHttpMethod(adapterRequest.method);
|
|
394
615
|
const schmockResponse = await awaitWithAbort(admittedHandle(effectiveMethod, adapterRequest.path, requestOptions), request.signal);
|
|
395
616
|
throwIfAborted(request.signal);
|
|
396
617
|
// Exception provenance is carried on the response as a non-enumerable
|
|
@@ -401,17 +622,7 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
401
622
|
const internalError = getResponseException(schmockResponse);
|
|
402
623
|
// Route not found — passthrough or 404
|
|
403
624
|
if (isRouteNotFound(schmockResponse)) {
|
|
404
|
-
|
|
405
|
-
return PASSTHROUGH;
|
|
406
|
-
}
|
|
407
|
-
return toFetchResponse({
|
|
408
|
-
status: 404,
|
|
409
|
-
body: {
|
|
410
|
-
error: "No matching mock route found",
|
|
411
|
-
code: "ROUTE_NOT_FOUND",
|
|
412
|
-
},
|
|
413
|
-
headers: { "content-type": "application/json" },
|
|
414
|
-
}, effectiveMethod);
|
|
625
|
+
return unroutedResult(passthrough, context);
|
|
415
626
|
}
|
|
416
627
|
// Apply beforeResponse hook
|
|
417
628
|
let response = schmockResponse;
|
|
@@ -429,9 +640,22 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
429
640
|
// Angular): a beforeResponse that rewrites an exception into a 503 or
|
|
430
641
|
// a 200 is honoured instead of being forced back to a formatted 500.
|
|
431
642
|
if (errorFormatter && internalError && response.status === 500) {
|
|
432
|
-
|
|
643
|
+
const seenRequest = adapterRequest;
|
|
644
|
+
return formattedErrorResponse({
|
|
645
|
+
formatter: (error) => errorFormatter(error, seenRequest),
|
|
646
|
+
error: internalError,
|
|
647
|
+
responseHeaders: response.headers,
|
|
648
|
+
context,
|
|
649
|
+
});
|
|
650
|
+
}
|
|
651
|
+
// A schmock() admission's handle() already normalized its own output
|
|
652
|
+
// for this method; with no hook to replace or mutate it, a second
|
|
653
|
+
// pass would only re-validate the same tree. Anything else, including
|
|
654
|
+
// a hand-written admission's response, is re-normalized.
|
|
655
|
+
if (isNormalizedAdmission(admission) && beforeResponse === undefined) {
|
|
656
|
+
return createFetchResponse(withDefaultContentType(schmockResponse), context);
|
|
433
657
|
}
|
|
434
|
-
return toFetchResponse(response,
|
|
658
|
+
return toFetchResponse(response, context);
|
|
435
659
|
}
|
|
436
660
|
catch (error) {
|
|
437
661
|
throwIfAborted(request.signal);
|
|
@@ -439,13 +663,23 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
439
663
|
throw error;
|
|
440
664
|
}
|
|
441
665
|
if (errorFormatter) {
|
|
442
|
-
|
|
666
|
+
// A formatter that throws here propagates and rejects the fetch; a
|
|
667
|
+
// body it returns that cannot be serialized falls back to the same
|
|
668
|
+
// INTERNAL_ERROR body the core-marked path uses.
|
|
669
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
670
|
+
// No request was built when the body itself could not be read.
|
|
671
|
+
const formatted = errorFormatter(failure, formatterRequest ?? {
|
|
672
|
+
method: request.method,
|
|
673
|
+
path,
|
|
674
|
+
headers: extractHeaders(request),
|
|
675
|
+
query: extractQuery(url),
|
|
676
|
+
});
|
|
443
677
|
throwIfAborted(request.signal);
|
|
444
|
-
return
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
678
|
+
return formattedErrorResponse({
|
|
679
|
+
formatter: () => formatted,
|
|
680
|
+
error: failure,
|
|
681
|
+
context,
|
|
682
|
+
});
|
|
449
683
|
}
|
|
450
684
|
throw error;
|
|
451
685
|
}
|
|
@@ -454,5 +688,5 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
454
688
|
}
|
|
455
689
|
}, (nextOptions) => {
|
|
456
690
|
currentOptions = nextOptions ?? {};
|
|
457
|
-
}, owner);
|
|
691
|
+
}, owner, observe);
|
|
458
692
|
}
|