@schmock/core 2.3.0 → 2.3.1
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/LICENSE +21 -0
- package/README.md +35 -0
- package/dist/abort.d.ts +3 -0
- package/dist/abort.d.ts.map +1 -0
- package/dist/abort.js +30 -0
- package/dist/builder.d.ts +31 -10
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +760 -190
- package/dist/constants.d.ts +38 -0
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +123 -2
- package/dist/errors.d.ts +14 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js +41 -6
- package/dist/helpers.d.ts +9 -0
- package/dist/helpers.d.ts.map +1 -1
- package/dist/helpers.js +18 -2
- package/dist/http-helpers.d.ts +39 -4
- package/dist/http-helpers.d.ts.map +1 -1
- package/dist/http-helpers.js +147 -49
- package/dist/index.d.ts +292 -34
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +8 -3
- package/dist/interceptor.d.ts +11 -1
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +316 -169
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +12 -3
- package/dist/plugin-pipeline.d.ts +3 -3
- package/dist/plugin-pipeline.d.ts.map +1 -1
- package/dist/plugin-pipeline.js +29 -14
- package/dist/response-normalizer.d.ts +16 -0
- package/dist/response-normalizer.d.ts.map +1 -0
- package/dist/response-normalizer.js +316 -0
- package/dist/response-parser.d.ts.map +1 -1
- package/dist/response-parser.js +39 -2
- package/dist/route-matcher.d.ts +3 -0
- package/dist/route-matcher.d.ts.map +1 -1
- package/dist/route-matcher.js +12 -6
- package/dist/types.d.ts +7 -2
- package/dist/types.d.ts.map +1 -1
- package/package.json +14 -6
- package/src/audit-core-builder.test.ts +0 -218
- package/src/audit-response-guard.test.ts +0 -38
- package/src/binary.test.ts +0 -126
- package/src/binary.ts +0 -11
- package/src/builder.test.ts +0 -289
- package/src/builder.ts +0 -748
- package/src/constants.test.ts +0 -99
- package/src/constants.ts +0 -73
- package/src/debug.test.ts +0 -241
- package/src/delay.test.ts +0 -319
- package/src/dist-shape.test.ts +0 -35
- package/src/errors.test.ts +0 -223
- package/src/errors.ts +0 -130
- package/src/factory.test.ts +0 -133
- package/src/helpers.test.ts +0 -147
- package/src/helpers.ts +0 -58
- package/src/http-helpers.test.ts +0 -39
- package/src/http-helpers.ts +0 -149
- package/src/index.ts +0 -152
- package/src/interceptor.test.ts +0 -488
- package/src/interceptor.ts +0 -407
- package/src/namespace.test.ts +0 -274
- package/src/parser.property.test.ts +0 -630
- package/src/parser.test.ts +0 -148
- package/src/parser.ts +0 -64
- package/src/plugin-pipeline.ts +0 -208
- package/src/plugin-system.test.ts +0 -697
- package/src/response-parser.ts +0 -114
- package/src/response-parsing.test.ts +0 -333
- package/src/route-matcher.ts +0 -69
- package/src/route-matching.test.ts +0 -394
- package/src/server.test.ts +0 -234
- package/src/smart-defaults.test.ts +0 -361
- package/src/steps/async-support.steps.ts +0 -421
- package/src/steps/audit-core-builder.steps.ts +0 -188
- package/src/steps/audit-onerror-tuple.steps.ts +0 -83
- package/src/steps/basic-usage.steps.ts +0 -245
- package/src/steps/developer-experience.steps.ts +0 -204
- package/src/steps/error-handling.steps.ts +0 -555
- package/src/steps/fetch-interceptor.steps.ts +0 -457
- package/src/steps/fluent-api.steps.ts +0 -255
- package/src/steps/http-methods.steps.ts +0 -342
- package/src/steps/lifecycle-events.steps.ts +0 -149
- package/src/steps/performance-reliability.steps.ts +0 -90
- package/src/steps/plugin-integration.steps.ts +0 -334
- package/src/steps/request-history.steps.ts +0 -458
- package/src/steps/response-delay.steps.ts +0 -88
- package/src/steps/route-key-format.steps.ts +0 -99
- package/src/steps/standalone-server.steps.ts +0 -227
- package/src/steps/state-concurrency.steps.ts +0 -167
- package/src/types.ts +0 -36
package/dist/interceptor.js
CHANGED
|
@@ -1,31 +1,140 @@
|
|
|
1
1
|
/// <reference path="../schmock.d.ts" />
|
|
2
|
+
import { awaitWithAbort, throwIfAborted } from "./abort.js";
|
|
2
3
|
import { isBinaryBody } from "./binary.js";
|
|
3
|
-
import { isRouteNotFound, toHttpMethod } from "./constants.js";
|
|
4
|
+
import { canonicalizePath, getResponseException, isRouteNotFound, toHttpMethod, } from "./constants.js";
|
|
5
|
+
import { normalizeResponse, serializeResponseBody, } from "./response-normalizer.js";
|
|
4
6
|
const PASSTHROUGH = Symbol("schmock.fetch.passthrough");
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return bytes;
|
|
11
|
-
}
|
|
7
|
+
// A lease whose baseUrl filter rejected the request never reached its handler.
|
|
8
|
+
// It is distinct from PASSTHROUGH so the dispatch loop can tell "this owner
|
|
9
|
+
// already ran" from "this lease was not interested in the request at all".
|
|
10
|
+
const FILTERED = Symbol("schmock.fetch.filtered");
|
|
11
|
+
const RELATIVE_REQUEST_BASE = "http://schmock.invalid/";
|
|
12
12
|
let activeSession;
|
|
13
|
+
function getRelativeRequestBase() {
|
|
14
|
+
const candidates = [
|
|
15
|
+
typeof document === "undefined" ? undefined : document.baseURI,
|
|
16
|
+
typeof location === "undefined" ? undefined : location.href,
|
|
17
|
+
];
|
|
18
|
+
for (const candidate of candidates) {
|
|
19
|
+
if (!candidate)
|
|
20
|
+
continue;
|
|
21
|
+
try {
|
|
22
|
+
return new URL(candidate).href;
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
// Ignore invalid environment globals and use the next fallback.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return RELATIVE_REQUEST_BASE;
|
|
29
|
+
}
|
|
30
|
+
// The Fetch standard stamps a content type when the body is extracted from a
|
|
31
|
+
// string or URLSearchParams. Node's Request constructor conforms; Bun's omits
|
|
32
|
+
// the header, so identical consumer code would otherwise deliver a string
|
|
33
|
+
// body on Node and an opaque ArrayBuffer on Bun.
|
|
34
|
+
function stampBodyContentType(request, body) {
|
|
35
|
+
if (body == null || request.headers.has("content-type"))
|
|
36
|
+
return;
|
|
37
|
+
if (typeof body === "string") {
|
|
38
|
+
request.headers.set("content-type", "text/plain;charset=UTF-8");
|
|
39
|
+
}
|
|
40
|
+
else if (body instanceof URLSearchParams) {
|
|
41
|
+
request.headers.set("content-type", "application/x-www-form-urlencoded;charset=UTF-8");
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
function normalizeFetchRequest(input, init) {
|
|
45
|
+
if (input instanceof Request) {
|
|
46
|
+
// Constructing a Request from another Request transfers its body. Use a
|
|
47
|
+
// clone when the body is inherited so the original remains passthrough-safe.
|
|
48
|
+
const source = init?.body == null ? input.clone() : input;
|
|
49
|
+
const request = new Request(source, init);
|
|
50
|
+
if (init?.body != null) {
|
|
51
|
+
stampBodyContentType(request, init.body);
|
|
52
|
+
}
|
|
53
|
+
else if (request.body != null && !request.headers.has("content-type")) {
|
|
54
|
+
// The body was inherited from the input Request, but init.headers
|
|
55
|
+
// replaces the whole header list, dropping the content type stamped
|
|
56
|
+
// when that body was extracted. Restore it so the handler still sees
|
|
57
|
+
// the body as its original kind. (Bun never stamps at construction,
|
|
58
|
+
// so a string body on a type-less input Request stays opaque there.)
|
|
59
|
+
const inheritedType = input.headers.get("content-type");
|
|
60
|
+
if (inheritedType !== null) {
|
|
61
|
+
request.headers.set("content-type", inheritedType);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const url = new URL(request.url);
|
|
65
|
+
return {
|
|
66
|
+
request,
|
|
67
|
+
url,
|
|
68
|
+
origin: url.origin,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
if (input instanceof URL) {
|
|
72
|
+
const request = new Request(input, init);
|
|
73
|
+
stampBodyContentType(request, init?.body);
|
|
74
|
+
const url = new URL(request.url);
|
|
75
|
+
return {
|
|
76
|
+
request,
|
|
77
|
+
url,
|
|
78
|
+
origin: url.origin,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
let inputUrl;
|
|
82
|
+
let origin;
|
|
83
|
+
try {
|
|
84
|
+
inputUrl = new URL(input);
|
|
85
|
+
origin = inputUrl.origin;
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
inputUrl = new URL(input, getRelativeRequestBase());
|
|
89
|
+
origin = null;
|
|
90
|
+
}
|
|
91
|
+
const request = new Request(inputUrl, init);
|
|
92
|
+
stampBodyContentType(request, init?.body);
|
|
93
|
+
return {
|
|
94
|
+
request,
|
|
95
|
+
url: new URL(request.url),
|
|
96
|
+
origin,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
13
99
|
function createInterceptorSession() {
|
|
14
100
|
const baselineFetch = globalThis.fetch;
|
|
15
101
|
const interceptors = [];
|
|
16
102
|
const dispatchFetch = async (input, init) => {
|
|
17
103
|
const snapshot = interceptors.slice();
|
|
104
|
+
if (snapshot.length === 0) {
|
|
105
|
+
return baselineFetch(input, init);
|
|
106
|
+
}
|
|
107
|
+
const normalizedRequest = normalizeFetchRequest(input, init);
|
|
108
|
+
throwIfAborted(normalizedRequest.request.signal);
|
|
109
|
+
// A mock is consulted at most once per request, however many leases it
|
|
110
|
+
// holds: without this, nested providers on one mock would run handle()
|
|
111
|
+
// — and emit request:start/notfound/end — once per lease.
|
|
112
|
+
const consultedOwners = new Set();
|
|
18
113
|
for (let index = snapshot.length - 1; index >= 0; index -= 1) {
|
|
19
|
-
const
|
|
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
|
+
}
|
|
20
126
|
if (result !== PASSTHROUGH) {
|
|
21
127
|
return result;
|
|
22
128
|
}
|
|
129
|
+
if (owner !== undefined) {
|
|
130
|
+
consultedOwners.add(owner);
|
|
131
|
+
}
|
|
23
132
|
}
|
|
24
|
-
return baselineFetch(
|
|
133
|
+
return awaitWithAbort(baselineFetch(normalizedRequest.request), normalizedRequest.request.signal);
|
|
25
134
|
};
|
|
26
135
|
return { baselineFetch, dispatchFetch, interceptors };
|
|
27
136
|
}
|
|
28
|
-
function registerInterceptor(intercept) {
|
|
137
|
+
function registerInterceptor(intercept, applyOptions, owner) {
|
|
29
138
|
let session = activeSession;
|
|
30
139
|
if (!session || globalThis.fetch !== session.dispatchFetch) {
|
|
31
140
|
session = createInterceptorSession();
|
|
@@ -33,7 +142,7 @@ function registerInterceptor(intercept) {
|
|
|
33
142
|
globalThis.fetch = session.dispatchFetch;
|
|
34
143
|
}
|
|
35
144
|
const token = Symbol("schmock.fetch.interceptor");
|
|
36
|
-
session.interceptors.push({ token, intercept });
|
|
145
|
+
session.interceptors.push({ token, owner, intercept });
|
|
37
146
|
let active = true;
|
|
38
147
|
return {
|
|
39
148
|
restore() {
|
|
@@ -55,44 +164,18 @@ function registerInterceptor(intercept) {
|
|
|
55
164
|
globalThis.fetch = session.baselineFetch;
|
|
56
165
|
}
|
|
57
166
|
},
|
|
167
|
+
update(options) {
|
|
168
|
+
// Reconfiguring never touches session.interceptors, so the lease keeps
|
|
169
|
+
// the dispatch position it was registered with.
|
|
170
|
+
if (!active)
|
|
171
|
+
return;
|
|
172
|
+
applyOptions(options);
|
|
173
|
+
},
|
|
58
174
|
get active() {
|
|
59
175
|
return active;
|
|
60
176
|
},
|
|
61
177
|
};
|
|
62
178
|
}
|
|
63
|
-
/**
|
|
64
|
-
* Extract pathname from a URL string, handling both absolute and relative URLs.
|
|
65
|
-
*/
|
|
66
|
-
function extractPathname(url) {
|
|
67
|
-
const queryStart = url.indexOf("?");
|
|
68
|
-
const urlWithoutQuery = queryStart === -1 ? url : url.slice(0, queryStart);
|
|
69
|
-
if (urlWithoutQuery.includes("://")) {
|
|
70
|
-
try {
|
|
71
|
-
return new URL(urlWithoutQuery).pathname;
|
|
72
|
-
}
|
|
73
|
-
catch {
|
|
74
|
-
// Fall through to simple extraction
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
if (!urlWithoutQuery.startsWith("/")) {
|
|
78
|
-
return `/${urlWithoutQuery}`;
|
|
79
|
-
}
|
|
80
|
-
return urlWithoutQuery;
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Extract origin (scheme + host + port) from a URL string, or null for
|
|
84
|
-
* relative URLs that don't carry one.
|
|
85
|
-
*/
|
|
86
|
-
function extractOrigin(url) {
|
|
87
|
-
if (!url.includes("://"))
|
|
88
|
-
return null;
|
|
89
|
-
try {
|
|
90
|
-
return new URL(url).origin;
|
|
91
|
-
}
|
|
92
|
-
catch {
|
|
93
|
-
return null;
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
179
|
/**
|
|
97
180
|
* Parse the user-supplied baseUrl option into its origin and path parts.
|
|
98
181
|
* - "/api" → { origin: null, path: "/api" }
|
|
@@ -106,109 +189,158 @@ function parseBaseUrl(baseUrl) {
|
|
|
106
189
|
if (baseUrl.includes("://")) {
|
|
107
190
|
try {
|
|
108
191
|
const parsed = new URL(baseUrl);
|
|
109
|
-
const
|
|
110
|
-
const path =
|
|
192
|
+
const canonicalPath = canonicalizePath(parsed.pathname);
|
|
193
|
+
const path = canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
|
|
111
194
|
return { origin: parsed.origin, path };
|
|
112
195
|
}
|
|
113
196
|
catch {
|
|
114
197
|
// Fall through to path-only handling
|
|
115
198
|
}
|
|
116
199
|
}
|
|
117
|
-
|
|
200
|
+
const canonicalPath = canonicalizePath(baseUrl);
|
|
201
|
+
const path = canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
|
|
202
|
+
return { origin: null, path };
|
|
118
203
|
}
|
|
119
|
-
/**
|
|
120
|
-
* Extract query parameters from a URL string.
|
|
121
|
-
*/
|
|
122
204
|
function extractQuery(url) {
|
|
123
|
-
|
|
124
|
-
if (queryStart === -1)
|
|
125
|
-
return {};
|
|
126
|
-
const params = new URLSearchParams(url.slice(queryStart + 1));
|
|
127
|
-
const result = {};
|
|
128
|
-
params.forEach((value, key) => {
|
|
129
|
-
result[key] = value;
|
|
130
|
-
});
|
|
131
|
-
return result;
|
|
205
|
+
return Object.fromEntries(url.searchParams);
|
|
132
206
|
}
|
|
133
|
-
|
|
134
|
-
* Extract headers from fetch init or Request object.
|
|
135
|
-
*/
|
|
136
|
-
function extractHeaders(input, init) {
|
|
207
|
+
function extractHeaders(request) {
|
|
137
208
|
const headers = {};
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
if (raw instanceof Headers) {
|
|
142
|
-
raw.forEach((value, key) => {
|
|
143
|
-
headers[key.toLowerCase()] = value;
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
else if (Array.isArray(raw)) {
|
|
147
|
-
for (const [key, value] of raw) {
|
|
148
|
-
headers[key.toLowerCase()] = value;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
for (const key of Object.keys(raw)) {
|
|
153
|
-
headers[key.toLowerCase()] = raw[key];
|
|
154
|
-
}
|
|
155
|
-
}
|
|
209
|
+
request.headers.forEach((value, key) => {
|
|
210
|
+
headers[key.toLowerCase()] = value;
|
|
211
|
+
});
|
|
156
212
|
return headers;
|
|
157
213
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
async function extractBody(
|
|
162
|
-
|
|
163
|
-
const bodyInit = init?.body ?? (input instanceof Request ? input.body : null);
|
|
164
|
-
if (bodyInit === null || bodyInit === undefined)
|
|
214
|
+
function normalizeMediaType(contentType) {
|
|
215
|
+
return contentType?.split(";", 1)[0].trim().toLowerCase() ?? "";
|
|
216
|
+
}
|
|
217
|
+
async function extractBody(request) {
|
|
218
|
+
if (request.body === null)
|
|
165
219
|
return undefined;
|
|
166
|
-
|
|
167
|
-
|
|
220
|
+
const body = request.clone();
|
|
221
|
+
const mediaType = normalizeMediaType(request.headers.get("content-type"));
|
|
222
|
+
if (mediaType === "application/json" || mediaType.endsWith("+json")) {
|
|
223
|
+
const text = await body.text();
|
|
168
224
|
try {
|
|
169
|
-
return JSON.parse(
|
|
225
|
+
return JSON.parse(text);
|
|
170
226
|
}
|
|
171
227
|
catch {
|
|
172
|
-
return
|
|
228
|
+
return text;
|
|
173
229
|
}
|
|
174
230
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
231
|
+
if (mediaType === "application/x-www-form-urlencoded") {
|
|
232
|
+
return Object.fromEntries(new URLSearchParams(await body.text()));
|
|
233
|
+
}
|
|
234
|
+
if (mediaType.startsWith("text/")) {
|
|
235
|
+
return body.text();
|
|
236
|
+
}
|
|
237
|
+
if (mediaType.startsWith("multipart/")) {
|
|
238
|
+
return body.formData();
|
|
239
|
+
}
|
|
240
|
+
return body.arrayBuffer();
|
|
241
|
+
}
|
|
242
|
+
function isAbortError(error) {
|
|
243
|
+
return (typeof error === "object" &&
|
|
244
|
+
error !== null &&
|
|
245
|
+
"name" in error &&
|
|
246
|
+
error.name === "AbortError");
|
|
247
|
+
}
|
|
248
|
+
function toFetchResponse(response, method) {
|
|
249
|
+
const headers = { ...response.headers };
|
|
250
|
+
const hasContentType = Object.keys(headers).some((name) => name.toLowerCase() === "content-type");
|
|
251
|
+
if (!hasContentType &&
|
|
252
|
+
response.body !== null &&
|
|
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
|
+
}
|
|
182
260
|
}
|
|
183
|
-
|
|
184
|
-
|
|
261
|
+
const normalized = normalizeResponse({ ...response, headers }, method);
|
|
262
|
+
return new Response(serializeResponseBody(normalized) ?? null, {
|
|
263
|
+
status: normalized.status,
|
|
264
|
+
headers: normalized.headers,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
/**
|
|
268
|
+
* Formatted error bodies are always JSON, so the replaced response's own
|
|
269
|
+
* content type must be dropped rather than inherited. Every case variant goes
|
|
270
|
+
* first: leaving a `Content-Type` beside the forced lowercase key makes the
|
|
271
|
+
* pair transport-invalid and the normalizer rejects it.
|
|
272
|
+
*/
|
|
273
|
+
function withJsonContentType(headers) {
|
|
274
|
+
const result = {};
|
|
275
|
+
for (const [name, value] of Object.entries(headers ?? {})) {
|
|
276
|
+
if (name.toLowerCase() === "content-type")
|
|
277
|
+
continue;
|
|
278
|
+
result[name] = value;
|
|
279
|
+
}
|
|
280
|
+
result["content-type"] = "application/json";
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Invoke the errorFormatter for a core-marked exception and build its
|
|
285
|
+
* response, falling back to a minimal safe body when the formatter throws or
|
|
286
|
+
* its result is not serializable.
|
|
287
|
+
*
|
|
288
|
+
* This helper is TOTAL — it never throws. It runs inside the interceptor's
|
|
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.
|
|
300
|
+
*/
|
|
301
|
+
function formatInterceptedError(errorFormatter, error, responseHeaders, method) {
|
|
302
|
+
try {
|
|
303
|
+
const formatted = errorFormatter(error);
|
|
185
304
|
try {
|
|
186
|
-
return
|
|
305
|
+
return toFetchResponse({
|
|
306
|
+
status: 500,
|
|
307
|
+
body: formatted,
|
|
308
|
+
headers: withJsonContentType(responseHeaders),
|
|
309
|
+
}, method);
|
|
187
310
|
}
|
|
188
311
|
catch {
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
}
|
|
312
|
+
// `formatted` is reused, so the formatter still fires exactly once.
|
|
313
|
+
return toFetchResponse({
|
|
314
|
+
status: 500,
|
|
315
|
+
body: formatted,
|
|
316
|
+
headers: { "content-type": "application/json" },
|
|
317
|
+
}, method);
|
|
195
318
|
}
|
|
196
319
|
}
|
|
197
|
-
|
|
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
|
+
}
|
|
198
327
|
}
|
|
199
328
|
/**
|
|
200
329
|
* Create a fetch interceptor that routes requests through mock.handle().
|
|
330
|
+
*
|
|
331
|
+
* `owner` identifies the mock behind the lease. Leases sharing an owner are
|
|
332
|
+
* consulted at most once per request, so a mock held by several leases runs
|
|
333
|
+
* its handler — and emits its lifecycle events — once per network request.
|
|
201
334
|
*/
|
|
202
|
-
export function createFetchInterceptor(handle, options = {}) {
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const path = extractPathname(urlString);
|
|
335
|
+
export function createFetchInterceptor(handle, options = {}, admitRequest, owner) {
|
|
336
|
+
// The options live in a mutable cell that each request reads once at its
|
|
337
|
+
// start. Reconfiguring a lease in place is what lets an adapter apply new
|
|
338
|
+
// hooks without re-registering — re-registration would move the lease to the
|
|
339
|
+
// front of the dispatch order and steal precedence from other mocks.
|
|
340
|
+
let currentOptions = options;
|
|
341
|
+
return registerInterceptor(async ({ request, url, origin }) => {
|
|
342
|
+
const { baseUrl, passthrough = true, beforeRequest, beforeResponse, errorFormatter, } = currentOptions;
|
|
343
|
+
const path = canonicalizePath(url.pathname);
|
|
212
344
|
// BaseUrl filter — non-matching requests go straight to real fetch.
|
|
213
345
|
// Two modes:
|
|
214
346
|
// - origin form ("https://api.example.com/v1"): require matching
|
|
@@ -217,95 +349,110 @@ export function createFetchInterceptor(handle, options = {}) {
|
|
|
217
349
|
// Both enforce a segment boundary so "/api" doesn't match "/apiv2".
|
|
218
350
|
if (baseUrl) {
|
|
219
351
|
const { origin: baseOrigin, path: basePath } = parseBaseUrl(baseUrl);
|
|
220
|
-
if (baseOrigin) {
|
|
221
|
-
|
|
222
|
-
if (reqOrigin !== baseOrigin) {
|
|
223
|
-
return PASSTHROUGH;
|
|
224
|
-
}
|
|
352
|
+
if (baseOrigin && origin !== baseOrigin) {
|
|
353
|
+
return FILTERED;
|
|
225
354
|
}
|
|
226
355
|
if (basePath) {
|
|
227
356
|
const isMatch = path === basePath || path.startsWith(`${basePath}/`);
|
|
228
357
|
if (!isMatch) {
|
|
229
|
-
return
|
|
358
|
+
return FILTERED;
|
|
230
359
|
}
|
|
231
360
|
}
|
|
232
361
|
}
|
|
233
|
-
|
|
234
|
-
const
|
|
235
|
-
const
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
let adapterRequest = {
|
|
239
|
-
method,
|
|
240
|
-
path,
|
|
241
|
-
headers,
|
|
242
|
-
body,
|
|
243
|
-
query,
|
|
244
|
-
};
|
|
362
|
+
throwIfAborted(request.signal);
|
|
363
|
+
const initialMethod = toHttpMethod(request.method);
|
|
364
|
+
const admission = admitRequest?.();
|
|
365
|
+
const admittedHandle = admission?.handle ?? handle;
|
|
366
|
+
let effectiveMethod = initialMethod;
|
|
245
367
|
try {
|
|
368
|
+
const body = await awaitWithAbort(extractBody(request), request.signal);
|
|
369
|
+
throwIfAborted(request.signal);
|
|
370
|
+
let adapterRequest = {
|
|
371
|
+
method: request.method,
|
|
372
|
+
path,
|
|
373
|
+
headers: extractHeaders(request),
|
|
374
|
+
body,
|
|
375
|
+
query: extractQuery(url),
|
|
376
|
+
};
|
|
246
377
|
// Apply beforeRequest hook
|
|
247
378
|
if (beforeRequest) {
|
|
248
|
-
|
|
379
|
+
throwIfAborted(request.signal);
|
|
380
|
+
const modified = await awaitWithAbort(beforeRequest(adapterRequest), request.signal);
|
|
381
|
+
throwIfAborted(request.signal);
|
|
249
382
|
if (modified) {
|
|
250
383
|
adapterRequest = modified;
|
|
251
384
|
}
|
|
252
385
|
}
|
|
253
|
-
|
|
386
|
+
throwIfAborted(request.signal);
|
|
387
|
+
const requestOptions = {
|
|
254
388
|
headers: adapterRequest.headers,
|
|
255
389
|
body: adapterRequest.body,
|
|
256
390
|
query: adapterRequest.query,
|
|
257
|
-
|
|
391
|
+
signal: request.signal,
|
|
392
|
+
};
|
|
393
|
+
effectiveMethod = toHttpMethod(adapterRequest.method);
|
|
394
|
+
const schmockResponse = await awaitWithAbort(admittedHandle(effectiveMethod, adapterRequest.path, requestOptions), request.signal);
|
|
395
|
+
throwIfAborted(request.signal);
|
|
396
|
+
// Exception provenance is carried on the response as a non-enumerable
|
|
397
|
+
// symbol, so it must be read BEFORE beforeResponse runs: the
|
|
398
|
+
// documented `{...response}` hook pattern copies only own enumerable
|
|
399
|
+
// properties and would otherwise strip the mark, silently bypassing
|
|
400
|
+
// errorFormatter.
|
|
401
|
+
const internalError = getResponseException(schmockResponse);
|
|
258
402
|
// Route not found — passthrough or 404
|
|
259
403
|
if (isRouteNotFound(schmockResponse)) {
|
|
260
404
|
if (passthrough) {
|
|
261
405
|
return PASSTHROUGH;
|
|
262
406
|
}
|
|
263
|
-
return
|
|
264
|
-
error: "No matching mock route found",
|
|
265
|
-
code: "ROUTE_NOT_FOUND",
|
|
266
|
-
}), {
|
|
407
|
+
return toFetchResponse({
|
|
267
408
|
status: 404,
|
|
409
|
+
body: {
|
|
410
|
+
error: "No matching mock route found",
|
|
411
|
+
code: "ROUTE_NOT_FOUND",
|
|
412
|
+
},
|
|
268
413
|
headers: { "content-type": "application/json" },
|
|
269
|
-
});
|
|
414
|
+
}, effectiveMethod);
|
|
270
415
|
}
|
|
271
416
|
// Apply beforeResponse hook
|
|
272
417
|
let response = schmockResponse;
|
|
273
418
|
if (beforeResponse) {
|
|
274
|
-
|
|
419
|
+
throwIfAborted(request.signal);
|
|
420
|
+
const modified = await awaitWithAbort(beforeResponse(response, adapterRequest), request.signal);
|
|
421
|
+
throwIfAborted(request.signal);
|
|
275
422
|
if (modified) {
|
|
276
423
|
response = modified;
|
|
277
424
|
}
|
|
278
425
|
}
|
|
279
|
-
//
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
: "application/json");
|
|
426
|
+
// Only core-marked exceptions reach errorFormatter; a user-defined 500
|
|
427
|
+
// with an error-shaped body stays an ordinary domain response. The
|
|
428
|
+
// POST-hook status gates the replacement (matching Express and
|
|
429
|
+
// Angular): a beforeResponse that rewrites an exception into a 503 or
|
|
430
|
+
// a 200 is honoured instead of being forced back to a formatted 500.
|
|
431
|
+
if (errorFormatter && internalError && response.status === 500) {
|
|
432
|
+
return formatInterceptedError(errorFormatter, internalError, response.headers, effectiveMethod);
|
|
287
433
|
}
|
|
288
|
-
|
|
289
|
-
? null
|
|
290
|
-
: typeof response.body === "string"
|
|
291
|
-
? response.body
|
|
292
|
-
: isBinaryBody(response.body)
|
|
293
|
-
? toFetchBinaryBody(response.body)
|
|
294
|
-
: JSON.stringify(response.body);
|
|
295
|
-
return new Response(responseBody, {
|
|
296
|
-
status: response.status,
|
|
297
|
-
headers: responseHeaders,
|
|
298
|
-
});
|
|
434
|
+
return toFetchResponse(response, effectiveMethod);
|
|
299
435
|
}
|
|
300
436
|
catch (error) {
|
|
437
|
+
throwIfAborted(request.signal);
|
|
438
|
+
if (isAbortError(error)) {
|
|
439
|
+
throw error;
|
|
440
|
+
}
|
|
301
441
|
if (errorFormatter) {
|
|
302
442
|
const formatted = errorFormatter(error instanceof Error ? error : new Error(String(error)));
|
|
303
|
-
|
|
443
|
+
throwIfAborted(request.signal);
|
|
444
|
+
return toFetchResponse({
|
|
304
445
|
status: 500,
|
|
446
|
+
body: formatted,
|
|
305
447
|
headers: { "content-type": "application/json" },
|
|
306
|
-
});
|
|
448
|
+
}, effectiveMethod);
|
|
307
449
|
}
|
|
308
450
|
throw error;
|
|
309
451
|
}
|
|
310
|
-
|
|
452
|
+
finally {
|
|
453
|
+
admission?.release();
|
|
454
|
+
}
|
|
455
|
+
}, (nextOptions) => {
|
|
456
|
+
currentOptions = nextOptions ?? {};
|
|
457
|
+
}, owner);
|
|
311
458
|
}
|
package/dist/parser.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,
|
|
1
|
+
{"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../src/parser.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,YAAY,CAAC;AAE7C,MAAM,WAAW,WAAW;IAC1B,MAAM,EAAE,UAAU,CAAC;IACnB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;IAChB,MAAM,EAAE,MAAM,EAAE,CAAC;CAClB;AAED;;;;;;;;;;GAUG;AACH,wBAAgB,aAAa,CAAC,QAAQ,EAAE,MAAM,GAAG,WAAW,CAmD3D"}
|
package/dist/parser.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { toHttpMethod } from "./constants.js";
|
|
1
|
+
import { canonicalizePath, normalizePath, toHttpMethod } from "./constants.js";
|
|
2
2
|
import { RouteParseError } from "./errors.js";
|
|
3
3
|
/**
|
|
4
4
|
* Parse 'METHOD /path' route key format
|
|
@@ -12,11 +12,20 @@ import { RouteParseError } from "./errors.js";
|
|
|
12
12
|
* // => { method: 'GET', path: '/users/:id', pattern: /^\/users\/([^/]+)$/, params: ['id'] }
|
|
13
13
|
*/
|
|
14
14
|
export function parseRouteKey(routeKey) {
|
|
15
|
-
|
|
15
|
+
// The path group must start with "/": every transport (server, interceptor,
|
|
16
|
+
// adapters) delivers a leading-slash pathname, so a slash-less key such as
|
|
17
|
+
// "GET users" compiles a route no request can ever reach. `.*` is kept
|
|
18
|
+
// deliberately — hostile-but-reachable segments (spaces, tabs, unicode) must
|
|
19
|
+
// still parse.
|
|
20
|
+
const match = routeKey.match(/^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS) (\/.*)$/);
|
|
16
21
|
if (!match) {
|
|
17
22
|
throw new RouteParseError(routeKey, 'Expected format: "METHOD /path" (e.g., "GET /users")');
|
|
18
23
|
}
|
|
19
|
-
const [, method,
|
|
24
|
+
const [, method, rawPath] = match;
|
|
25
|
+
// Canonicalize once, here, so `path`, `pattern`, the duplicate check, the
|
|
26
|
+
// static-route Map key and getRoutes() all agree on one spelling: the
|
|
27
|
+
// percent-encoded transport form with a single trailing slash stripped.
|
|
28
|
+
const path = normalizePath(canonicalizePath(rawPath));
|
|
20
29
|
// Parameter names are restricted to [A-Za-z0-9_-] so that surrounding
|
|
21
30
|
// literals (".json", brackets, parens, etc.) terminate the name and can
|
|
22
31
|
// be escaped without bleeding into the parameter regex. Build the
|
|
@@ -9,14 +9,14 @@ interface PipelineResult {
|
|
|
9
9
|
requestShortCircuited?: boolean;
|
|
10
10
|
}
|
|
11
11
|
/** Run request guards before route code is allowed to execute. */
|
|
12
|
-
export declare function runPluginBeforeRequest(plugins: Schmock.Plugin[], context: Schmock.PluginContext, logger: PipelineLogger): Promise<PipelineResult>;
|
|
12
|
+
export declare function runPluginBeforeRequest(plugins: readonly Schmock.Plugin[], context: Schmock.PluginContext, logger: PipelineLogger, signal?: AbortSignal | undefined): Promise<PipelineResult>;
|
|
13
13
|
/** Give pipeline error handlers a chance to recover a route-generator error. */
|
|
14
|
-
export declare function recoverGeneratorError(plugins: Schmock.Plugin[], context: Schmock.PluginContext, error: unknown, logger: PipelineLogger): Promise<PipelineResult>;
|
|
14
|
+
export declare function recoverGeneratorError(plugins: readonly Schmock.Plugin[], context: Schmock.PluginContext, error: unknown, logger: PipelineLogger, signal?: AbortSignal | undefined): Promise<PipelineResult>;
|
|
15
15
|
/**
|
|
16
16
|
* Run all registered plugins in sequence
|
|
17
17
|
* First plugin to set response becomes generator, subsequent plugins transform
|
|
18
18
|
* Handles plugin errors via onError hooks
|
|
19
19
|
*/
|
|
20
|
-
export declare function runPluginPipeline(plugins: Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger): Promise<PipelineResult>;
|
|
20
|
+
export declare function runPluginPipeline(plugins: readonly Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger, signal?: AbortSignal | undefined): Promise<PipelineResult>;
|
|
21
21
|
export {};
|
|
22
22
|
//# sourceMappingURL=plugin-pipeline.d.ts.map
|