@schmock/core 2.4.0 → 2.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -0
- package/dist/abort.d.ts +11 -1
- package/dist/abort.js +13 -2
- package/dist/adapter.d.ts +19 -0
- package/dist/adapter.js +17 -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 +397 -903
- 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 +43 -0
- package/dist/generations.js +75 -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 +230 -0
- package/dist/http-helpers.d.ts +110 -5
- package/dist/http-helpers.js +328 -46
- package/dist/index.d.ts +213 -31
- package/dist/index.js +17 -9
- package/dist/interceptor.d.ts +15 -11
- package/dist/interceptor.js +241 -164
- 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 +40 -0
- package/dist/plugin-hooks.js +192 -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/types.d.ts +27 -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,14 +1,29 @@
|
|
|
1
1
|
/// <reference path="../schmock.d.ts" />
|
|
2
2
|
import { awaitWithAbort, throwIfAborted } from "./abort.js";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import { normalizeResponse, serializeResponseBody, } from "./response-normalizer.js";
|
|
3
|
+
import { canonicalizePath, getResponseException, isHttpMethod, isRouteNotFound, matchPathPrefix, parsePathPrefix, } from "./constants.js";
|
|
4
|
+
import { buildFormattedErrorResponse, buildJsonErrorResponse, normalizeResponse, serializeResponseBody, withDefaultContentType, } from "./response-normalizer.js";
|
|
6
5
|
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".
|
|
6
|
+
// A lease whose baseUrl filter rejected the request never reached its handler:
|
|
7
|
+
// it was not interested in the request at all, and claimed nothing.
|
|
10
8
|
const FILTERED = Symbol("schmock.fetch.filtered");
|
|
9
|
+
// A newer lease of the same mock already asked it this exact request, so
|
|
10
|
+
// asking again would only repeat the answer and its lifecycle events.
|
|
11
|
+
const ALREADY_CONSULTED = Symbol("schmock.fetch.already-consulted");
|
|
11
12
|
const RELATIVE_REQUEST_BASE = "http://schmock.invalid/";
|
|
13
|
+
/**
|
|
14
|
+
* Marks an admission whose `handle()` already returns responses normalized
|
|
15
|
+
* for the request method (hop-by-hop headers dropped, a HEAD body stripped):
|
|
16
|
+
* only the admissions a `schmock()` instance of this copy creates. The
|
|
17
|
+
* interceptor re-normalizes the responses of any other admission, such as a
|
|
18
|
+
* hand-written one passed to `createFetchInterceptor`. Deliberately
|
|
19
|
+
* unregistered: an admission from a second copy of `@schmock/core` is simply
|
|
20
|
+
* normalized again.
|
|
21
|
+
*/
|
|
22
|
+
export const NORMALIZED_ADMISSION_KEY = Symbol("schmock.normalized-admission");
|
|
23
|
+
function isNormalizedAdmission(admission) {
|
|
24
|
+
return (admission !== undefined &&
|
|
25
|
+
Reflect.get(admission, NORMALIZED_ADMISSION_KEY) === true);
|
|
26
|
+
}
|
|
12
27
|
let activeSession;
|
|
13
28
|
function getRelativeRequestBase() {
|
|
14
29
|
const candidates = [
|
|
@@ -106,29 +121,42 @@ function createInterceptorSession() {
|
|
|
106
121
|
}
|
|
107
122
|
const normalizedRequest = normalizeFetchRequest(input, init);
|
|
108
123
|
throwIfAborted(normalizedRequest.request.signal);
|
|
109
|
-
// A mock is
|
|
110
|
-
// holds: without this, nested providers on one mock would
|
|
111
|
-
// — and emit request:start/notfound/end — once per lease.
|
|
112
|
-
|
|
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
|
+
};
|
|
113
144
|
for (let index = snapshot.length - 1; index >= 0; index -= 1) {
|
|
114
145
|
const registered = snapshot[index];
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
}
|
|
119
|
-
const result = await awaitWithAbort(registered.intercept(normalizedRequest), normalizedRequest.request.signal);
|
|
146
|
+
const result = await awaitWithAbort(registered.intercept({
|
|
147
|
+
request: normalizedRequest,
|
|
148
|
+
claim: claimFor(registered.owner),
|
|
149
|
+
}), normalizedRequest.request.signal);
|
|
120
150
|
throwIfAborted(normalizedRequest.request.signal);
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
|
|
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) {
|
|
124
157
|
continue;
|
|
125
158
|
}
|
|
126
|
-
|
|
127
|
-
return result;
|
|
128
|
-
}
|
|
129
|
-
if (owner !== undefined) {
|
|
130
|
-
consultedOwners.add(owner);
|
|
131
|
-
}
|
|
159
|
+
return result;
|
|
132
160
|
}
|
|
133
161
|
return awaitWithAbort(baselineFetch(normalizedRequest.request), normalizedRequest.request.signal);
|
|
134
162
|
};
|
|
@@ -176,31 +204,6 @@ function registerInterceptor(intercept, applyOptions, owner) {
|
|
|
176
204
|
},
|
|
177
205
|
};
|
|
178
206
|
}
|
|
179
|
-
/**
|
|
180
|
-
* Parse the user-supplied baseUrl option into its origin and path parts.
|
|
181
|
-
* - "/api" → { origin: null, path: "/api" }
|
|
182
|
-
* - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
|
|
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/".
|
|
187
|
-
*/
|
|
188
|
-
function parseBaseUrl(baseUrl) {
|
|
189
|
-
if (baseUrl.includes("://")) {
|
|
190
|
-
try {
|
|
191
|
-
const parsed = new URL(baseUrl);
|
|
192
|
-
const canonicalPath = canonicalizePath(parsed.pathname);
|
|
193
|
-
const path = canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
|
|
194
|
-
return { origin: parsed.origin, path };
|
|
195
|
-
}
|
|
196
|
-
catch {
|
|
197
|
-
// Fall through to path-only handling
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
|
-
const canonicalPath = canonicalizePath(baseUrl);
|
|
201
|
-
const path = canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
|
|
202
|
-
return { origin: null, path };
|
|
203
|
-
}
|
|
204
207
|
function extractQuery(url) {
|
|
205
208
|
return Object.fromEntries(url.searchParams);
|
|
206
209
|
}
|
|
@@ -216,18 +219,25 @@ function normalizeMediaType(contentType) {
|
|
|
216
219
|
}
|
|
217
220
|
async function extractBody(request) {
|
|
218
221
|
if (request.body === null)
|
|
219
|
-
return undefined;
|
|
220
|
-
const body = request.clone();
|
|
222
|
+
return { value: undefined, malformedJson: false };
|
|
221
223
|
const mediaType = normalizeMediaType(request.headers.get("content-type"));
|
|
222
|
-
if (mediaType
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
224
|
+
if (mediaType !== "application/json" && !mediaType.endsWith("+json")) {
|
|
225
|
+
return { value: await extractNonJsonBody(request), malformedJson: false };
|
|
226
|
+
}
|
|
227
|
+
const text = await request.clone().text();
|
|
228
|
+
// An empty JSON body is no body at all, as the Node ingress reads it.
|
|
229
|
+
if (text === "")
|
|
230
|
+
return { value: undefined, malformedJson: false };
|
|
231
|
+
try {
|
|
232
|
+
return { value: JSON.parse(text), malformedJson: false };
|
|
233
|
+
}
|
|
234
|
+
catch {
|
|
235
|
+
return { value: text, malformedJson: true };
|
|
230
236
|
}
|
|
237
|
+
}
|
|
238
|
+
async function extractNonJsonBody(request) {
|
|
239
|
+
const body = request.clone();
|
|
240
|
+
const mediaType = normalizeMediaType(request.headers.get("content-type"));
|
|
231
241
|
if (mediaType === "application/x-www-form-urlencoded") {
|
|
232
242
|
return Object.fromEntries(new URLSearchParams(await body.text()));
|
|
233
243
|
}
|
|
@@ -239,98 +249,99 @@ async function extractBody(request) {
|
|
|
239
249
|
}
|
|
240
250
|
return body.arrayBuffer();
|
|
241
251
|
}
|
|
252
|
+
/** An admission's route probe (`hasRoute`), when it carries one. */
|
|
253
|
+
function routeProbeOf(admission) {
|
|
254
|
+
if (admission === undefined)
|
|
255
|
+
return undefined;
|
|
256
|
+
const probe = Reflect.get(admission, "hasRoute");
|
|
257
|
+
if (typeof probe !== "function")
|
|
258
|
+
return undefined;
|
|
259
|
+
// Anything but a definite `false` counts as a route, so an unexpected
|
|
260
|
+
// answer, or a throw, only costs the body read the probe would have saved.
|
|
261
|
+
return (method, path) => {
|
|
262
|
+
try {
|
|
263
|
+
return Reflect.apply(probe, admission, [method, path]) !== false;
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return true;
|
|
267
|
+
}
|
|
268
|
+
};
|
|
269
|
+
}
|
|
242
270
|
function isAbortError(error) {
|
|
243
271
|
return (typeof error === "object" &&
|
|
244
272
|
error !== null &&
|
|
245
273
|
"name" in error &&
|
|
246
274
|
error.name === "AbortError");
|
|
247
275
|
}
|
|
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, {
|
|
276
|
+
/**
|
|
277
|
+
* Build the fetch Response from an already-normalized Schmock response.
|
|
278
|
+
*/
|
|
279
|
+
function createFetchResponse(normalized, context) {
|
|
280
|
+
const response = new Response(serializeResponseBody(normalized) ?? null, {
|
|
263
281
|
status: normalized.status,
|
|
264
282
|
headers: normalized.headers,
|
|
265
283
|
});
|
|
284
|
+
// A constructed Response has an empty url. Real fetch reports the request
|
|
285
|
+
// URL, and code resolving links with `new URL(next, res.url)` needs it.
|
|
286
|
+
Object.defineProperty(response, "url", { value: context.url });
|
|
287
|
+
return response;
|
|
288
|
+
}
|
|
289
|
+
function toFetchResponse(response, context) {
|
|
290
|
+
return createFetchResponse(normalizeResponse(withDefaultContentType(response), context.method), context);
|
|
291
|
+
}
|
|
292
|
+
function jsonErrorResponse(input) {
|
|
293
|
+
return createFetchResponse(buildJsonErrorResponse({
|
|
294
|
+
status: input.status,
|
|
295
|
+
error: input.error,
|
|
296
|
+
code: input.code,
|
|
297
|
+
method: input.context.method,
|
|
298
|
+
}), input.context);
|
|
266
299
|
}
|
|
267
300
|
/**
|
|
268
|
-
*
|
|
269
|
-
*
|
|
270
|
-
* first: leaving a `Content-Type` beside the forced lowercase key makes the
|
|
271
|
-
* pair transport-invalid and the normalizer rejects it.
|
|
301
|
+
* A request this lease owns but cannot route: pass it on, or answer the same
|
|
302
|
+
* 404 a route miss gets when passthrough is off.
|
|
272
303
|
*/
|
|
273
|
-
function
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
304
|
+
function unroutedResult(passthrough, context) {
|
|
305
|
+
if (passthrough)
|
|
306
|
+
return PASSTHROUGH;
|
|
307
|
+
return jsonErrorResponse({
|
|
308
|
+
status: 404,
|
|
309
|
+
error: "No matching mock route found",
|
|
310
|
+
code: "ROUTE_NOT_FOUND",
|
|
311
|
+
context,
|
|
312
|
+
});
|
|
282
313
|
}
|
|
283
314
|
/**
|
|
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.
|
|
315
|
+
* The fetch Response for an errorFormatter result, built by the shared
|
|
316
|
+
* {@link buildFormattedErrorResponse}. TOTAL: it never throws, and the
|
|
317
|
+
* formatter runs exactly once. It runs inside the interceptor's `try`, so an
|
|
318
|
+
* escaping error would land in the catch below and invoke the formatter a
|
|
319
|
+
* second time.
|
|
300
320
|
*/
|
|
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
|
-
}
|
|
321
|
+
function formattedErrorResponse(input) {
|
|
322
|
+
return createFetchResponse(buildFormattedErrorResponse({
|
|
323
|
+
formatter: input.formatter,
|
|
324
|
+
error: input.error,
|
|
325
|
+
inheritedHeaders: input.responseHeaders,
|
|
326
|
+
method: input.context.method,
|
|
327
|
+
}), input.context);
|
|
328
|
+
}
|
|
329
|
+
/** Fetch reports the request URL without its fragment. */
|
|
330
|
+
function responseUrlOf(url) {
|
|
331
|
+
const responseUrl = new URL(url.href);
|
|
332
|
+
responseUrl.hash = "";
|
|
333
|
+
return responseUrl.href;
|
|
334
|
+
}
|
|
335
|
+
function effectiveRequestKey(method, path) {
|
|
336
|
+
return `${method} ${canonicalizePath(path)}`;
|
|
327
337
|
}
|
|
328
338
|
/**
|
|
329
339
|
* Create a fetch interceptor that routes requests through mock.handle().
|
|
330
340
|
*
|
|
331
|
-
* `owner` identifies the mock behind the lease. Leases sharing an owner
|
|
332
|
-
*
|
|
333
|
-
*
|
|
341
|
+
* `owner` identifies the mock behind the lease. Leases sharing an owner ask it
|
|
342
|
+
* each distinct effective request (method and path after the lease's own
|
|
343
|
+
* beforeRequest) at most once, so a mock held by several leases runs its
|
|
344
|
+
* handler — and emits its lifecycle events — once per request it is asked.
|
|
334
345
|
*/
|
|
335
346
|
export function createFetchInterceptor(handle, options = {}, admitRequest, owner) {
|
|
336
347
|
// The options live in a mutable cell that each request reads once at its
|
|
@@ -338,7 +349,7 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
338
349
|
// hooks without re-registering — re-registration would move the lease to the
|
|
339
350
|
// front of the dispatch order and steal precedence from other mocks.
|
|
340
351
|
let currentOptions = options;
|
|
341
|
-
return registerInterceptor(async ({ request, url, origin }) => {
|
|
352
|
+
return registerInterceptor(async ({ request: { request, url, origin }, claim, }) => {
|
|
342
353
|
const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = currentOptions;
|
|
343
354
|
const path = canonicalizePath(url.pathname);
|
|
344
355
|
// BaseUrl filter — non-matching requests go straight to real fetch.
|
|
@@ -348,32 +359,74 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
348
359
|
// - path form ("/api"): match pathname prefix only.
|
|
349
360
|
// Both enforce a segment boundary so "/api" doesn't match "/apiv2".
|
|
350
361
|
if (baseUrl) {
|
|
351
|
-
const
|
|
352
|
-
if (
|
|
362
|
+
const base = parsePathPrefix(baseUrl);
|
|
363
|
+
if (base.origin && origin !== base.origin) {
|
|
353
364
|
return FILTERED;
|
|
354
365
|
}
|
|
355
|
-
if (
|
|
356
|
-
|
|
357
|
-
if (!isMatch) {
|
|
358
|
-
return FILTERED;
|
|
359
|
-
}
|
|
366
|
+
if (!matchPathPrefix(base, path)) {
|
|
367
|
+
return FILTERED;
|
|
360
368
|
}
|
|
361
369
|
}
|
|
362
370
|
throwIfAborted(request.signal);
|
|
363
|
-
const
|
|
371
|
+
const context = {
|
|
372
|
+
method: request.method,
|
|
373
|
+
url: responseUrlOf(url),
|
|
374
|
+
};
|
|
375
|
+
const initialMethod = request.method.toUpperCase();
|
|
376
|
+
// Without a beforeRequest hook (which may change the method or path)
|
|
377
|
+
// the effective request is already known, so it is claimed before this
|
|
378
|
+
// lease can answer anything: once a newer lease of the same mock has
|
|
379
|
+
// passed it through, an older passthrough:false lease must not answer
|
|
380
|
+
// it with a 404 or a malformed-JSON 400. A lease with a hook claims only
|
|
381
|
+
// after the hook, so its own 400 for a malformed JSON body (which comes
|
|
382
|
+
// before the hook) still answers.
|
|
383
|
+
if (beforeRequest === undefined &&
|
|
384
|
+
!claim(effectiveRequestKey(initialMethod, path))) {
|
|
385
|
+
return ALREADY_CONSULTED;
|
|
386
|
+
}
|
|
387
|
+
// No route can ever match a method outside the supported set (WebDAV's
|
|
388
|
+
// PROPFIND, a CDN PURGE), so it is a miss like any other rather than a
|
|
389
|
+
// rejected fetch. Checked before admission, which it never needs.
|
|
390
|
+
if (!isHttpMethod(initialMethod)) {
|
|
391
|
+
return unroutedResult(passthrough, context);
|
|
392
|
+
}
|
|
393
|
+
context.method = initialMethod;
|
|
364
394
|
const admission = admitRequest?.();
|
|
365
395
|
const admittedHandle = admission?.handle ?? handle;
|
|
366
|
-
|
|
396
|
+
// Without a beforeRequest hook (which receives the body and may change
|
|
397
|
+
// the method or path), the request handle() will see is already known,
|
|
398
|
+
// so a passthrough lease asks the admission whether any route answers
|
|
399
|
+
// it. On a definite miss the body is never read: the request reaches
|
|
400
|
+
// the network untouched, and handle() still runs, without a body, so
|
|
401
|
+
// request:start/notfound/end are emitted exactly as before.
|
|
402
|
+
const routeProbe = passthrough && !beforeRequest ? routeProbeOf(admission) : undefined;
|
|
403
|
+
// The request handed to errorFormatter: the latest one this lease built,
|
|
404
|
+
// so it reflects beforeRequest once that hook has returned.
|
|
405
|
+
let formatterRequest;
|
|
367
406
|
try {
|
|
368
|
-
const body =
|
|
407
|
+
const body = routeProbe !== undefined && !routeProbe(initialMethod, path)
|
|
408
|
+
? { value: undefined, malformedJson: false }
|
|
409
|
+
: await awaitWithAbort(extractBody(request), request.signal);
|
|
369
410
|
throwIfAborted(request.signal);
|
|
411
|
+
// With passthrough off the lease owns every request that reaches it,
|
|
412
|
+
// as the Node server does, so a JSON body that does not parse gets
|
|
413
|
+
// the server's 400 before any route runs or history records it.
|
|
414
|
+
if (body.malformedJson && !passthrough) {
|
|
415
|
+
return jsonErrorResponse({
|
|
416
|
+
status: 400,
|
|
417
|
+
error: "Malformed JSON request body",
|
|
418
|
+
code: "MALFORMED_JSON",
|
|
419
|
+
context,
|
|
420
|
+
});
|
|
421
|
+
}
|
|
370
422
|
let adapterRequest = {
|
|
371
423
|
method: request.method,
|
|
372
424
|
path,
|
|
373
425
|
headers: extractHeaders(request),
|
|
374
|
-
body,
|
|
426
|
+
body: body.value,
|
|
375
427
|
query: extractQuery(url),
|
|
376
428
|
};
|
|
429
|
+
formatterRequest = adapterRequest;
|
|
377
430
|
// Apply beforeRequest hook
|
|
378
431
|
if (beforeRequest) {
|
|
379
432
|
throwIfAborted(request.signal);
|
|
@@ -381,16 +434,27 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
381
434
|
throwIfAborted(request.signal);
|
|
382
435
|
if (modified) {
|
|
383
436
|
adapterRequest = modified;
|
|
437
|
+
formatterRequest = modified;
|
|
384
438
|
}
|
|
385
439
|
}
|
|
386
440
|
throwIfAborted(request.signal);
|
|
441
|
+
// A hook may produce a method no route can match; that is a miss
|
|
442
|
+
// too, as the Angular adapter treats it.
|
|
443
|
+
const effectiveMethod = adapterRequest.method.toUpperCase();
|
|
444
|
+
if (!isHttpMethod(effectiveMethod)) {
|
|
445
|
+
return unroutedResult(passthrough, context);
|
|
446
|
+
}
|
|
447
|
+
context.method = effectiveMethod;
|
|
448
|
+
if (beforeRequest !== undefined &&
|
|
449
|
+
!claim(effectiveRequestKey(effectiveMethod, adapterRequest.path))) {
|
|
450
|
+
return ALREADY_CONSULTED;
|
|
451
|
+
}
|
|
387
452
|
const requestOptions = {
|
|
388
453
|
headers: adapterRequest.headers,
|
|
389
454
|
body: adapterRequest.body,
|
|
390
455
|
query: adapterRequest.query,
|
|
391
456
|
signal: request.signal,
|
|
392
457
|
};
|
|
393
|
-
effectiveMethod = toHttpMethod(adapterRequest.method);
|
|
394
458
|
const schmockResponse = await awaitWithAbort(admittedHandle(effectiveMethod, adapterRequest.path, requestOptions), request.signal);
|
|
395
459
|
throwIfAborted(request.signal);
|
|
396
460
|
// Exception provenance is carried on the response as a non-enumerable
|
|
@@ -401,17 +465,7 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
401
465
|
const internalError = getResponseException(schmockResponse);
|
|
402
466
|
// Route not found — passthrough or 404
|
|
403
467
|
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);
|
|
468
|
+
return unroutedResult(passthrough, context);
|
|
415
469
|
}
|
|
416
470
|
// Apply beforeResponse hook
|
|
417
471
|
let response = schmockResponse;
|
|
@@ -429,9 +483,22 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
429
483
|
// Angular): a beforeResponse that rewrites an exception into a 503 or
|
|
430
484
|
// a 200 is honoured instead of being forced back to a formatted 500.
|
|
431
485
|
if (errorFormatter && internalError && response.status === 500) {
|
|
432
|
-
|
|
486
|
+
const seenRequest = adapterRequest;
|
|
487
|
+
return formattedErrorResponse({
|
|
488
|
+
formatter: (error) => errorFormatter(error, seenRequest),
|
|
489
|
+
error: internalError,
|
|
490
|
+
responseHeaders: response.headers,
|
|
491
|
+
context,
|
|
492
|
+
});
|
|
493
|
+
}
|
|
494
|
+
// A schmock() admission's handle() already normalized its own output
|
|
495
|
+
// for this method; with no hook to replace or mutate it, a second
|
|
496
|
+
// pass would only re-validate the same tree. Anything else, including
|
|
497
|
+
// a hand-written admission's response, is re-normalized.
|
|
498
|
+
if (isNormalizedAdmission(admission) && beforeResponse === undefined) {
|
|
499
|
+
return createFetchResponse(withDefaultContentType(schmockResponse), context);
|
|
433
500
|
}
|
|
434
|
-
return toFetchResponse(response,
|
|
501
|
+
return toFetchResponse(response, context);
|
|
435
502
|
}
|
|
436
503
|
catch (error) {
|
|
437
504
|
throwIfAborted(request.signal);
|
|
@@ -439,13 +506,23 @@ export function createFetchInterceptor(handle, options = {}, admitRequest, owner
|
|
|
439
506
|
throw error;
|
|
440
507
|
}
|
|
441
508
|
if (errorFormatter) {
|
|
442
|
-
|
|
509
|
+
// A formatter that throws here propagates and rejects the fetch; a
|
|
510
|
+
// body it returns that cannot be serialized falls back to the same
|
|
511
|
+
// INTERNAL_ERROR body the core-marked path uses.
|
|
512
|
+
const failure = error instanceof Error ? error : new Error(String(error));
|
|
513
|
+
// No request was built when the body itself could not be read.
|
|
514
|
+
const formatted = errorFormatter(failure, formatterRequest ?? {
|
|
515
|
+
method: request.method,
|
|
516
|
+
path,
|
|
517
|
+
headers: extractHeaders(request),
|
|
518
|
+
query: extractQuery(url),
|
|
519
|
+
});
|
|
443
520
|
throwIfAborted(request.signal);
|
|
444
|
-
return
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
}
|
|
521
|
+
return formattedErrorResponse({
|
|
522
|
+
formatter: () => formatted,
|
|
523
|
+
error: failure,
|
|
524
|
+
context,
|
|
525
|
+
});
|
|
449
526
|
}
|
|
450
527
|
throw error;
|
|
451
528
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { DebugLogger } from "./debug-logger.js";
|
|
2
|
+
interface NodeServerControllerOptions {
|
|
3
|
+
/**
|
|
4
|
+
* Admit one request against the mock. Called on arrival, before the request
|
|
5
|
+
* is parsed, and released once it is answered.
|
|
6
|
+
*/
|
|
7
|
+
readonly admitRequest: () => Schmock.RequestAdmission;
|
|
8
|
+
readonly logger: DebugLogger;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The standalone HTTP server behind `mock.listen()` / `mock.close()`.
|
|
12
|
+
*
|
|
13
|
+
* Owns the start and close state machines: at most one server is running or
|
|
14
|
+
* starting, a `close()` during start-up cancels the start, and a new start
|
|
15
|
+
* waits for every earlier server to finish closing (the close barrier) so a
|
|
16
|
+
* restart on the same port never races the old socket.
|
|
17
|
+
*
|
|
18
|
+
* `node:http` is imported lazily, on the first `listen()`, so a browser bundle
|
|
19
|
+
* that never listens never pulls it in (issue #395).
|
|
20
|
+
*/
|
|
21
|
+
export declare class NodeServerController {
|
|
22
|
+
#private;
|
|
23
|
+
constructor(options: NodeServerControllerOptions);
|
|
24
|
+
listen(port: number, hostname: string): Promise<Schmock.ServerInfo>;
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
export {};
|