@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/builder.js
CHANGED
|
@@ -1,12 +1,130 @@
|
|
|
1
|
+
import { awaitWithAbort, throwIfAborted } from "./abort.js";
|
|
1
2
|
import { isBinaryBody } from "./binary.js";
|
|
2
|
-
import { normalizePath, toHttpMethod } from "./constants.js";
|
|
3
|
+
import { canonicalizePath, markResponseException, markRouteNotFound, normalizePath, toHttpMethod, } from "./constants.js";
|
|
3
4
|
import { errorMessage, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
|
|
4
|
-
import { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
|
|
5
|
+
import { collectBody, HttpIngressError, parseNodeHeaders, parseNodeQuery, writeRejectedSchmockResponse, writeSchmockResponse, } from "./http-helpers.js";
|
|
5
6
|
import { createFetchInterceptor } from "./interceptor.js";
|
|
6
7
|
import { parseRouteKey } from "./parser.js";
|
|
7
8
|
import { recoverGeneratorError, runPluginBeforeRequest, runPluginPipeline, } from "./plugin-pipeline.js";
|
|
9
|
+
import { normalizeResponse } from "./response-normalizer.js";
|
|
8
10
|
import { parseResponse } from "./response-parser.js";
|
|
9
11
|
import { extractParams, findRoute, isGeneratorFunction, } from "./route-matcher.js";
|
|
12
|
+
function isThenable(value) {
|
|
13
|
+
return (typeof value === "object" &&
|
|
14
|
+
value !== null &&
|
|
15
|
+
"then" in value &&
|
|
16
|
+
typeof value.then === "function");
|
|
17
|
+
}
|
|
18
|
+
function unavailableHistoryValue(value) {
|
|
19
|
+
let type = typeof value;
|
|
20
|
+
if (typeof value === "object" && value !== null) {
|
|
21
|
+
try {
|
|
22
|
+
type = Object.prototype.toString.call(value);
|
|
23
|
+
}
|
|
24
|
+
catch {
|
|
25
|
+
type = "object";
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
kind: "unavailable",
|
|
30
|
+
reason: "not-structured-cloneable",
|
|
31
|
+
type,
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
function removeSharedMemory(value, seen = new WeakMap()) {
|
|
35
|
+
if (typeof value !== "object" || value === null)
|
|
36
|
+
return value;
|
|
37
|
+
const existing = seen.get(value);
|
|
38
|
+
if (existing !== undefined)
|
|
39
|
+
return existing;
|
|
40
|
+
if (typeof SharedArrayBuffer !== "undefined" &&
|
|
41
|
+
value instanceof SharedArrayBuffer) {
|
|
42
|
+
const copy = Uint8Array.from(new Uint8Array(value)).buffer;
|
|
43
|
+
seen.set(value, copy);
|
|
44
|
+
return copy;
|
|
45
|
+
}
|
|
46
|
+
if (ArrayBuffer.isView(value) &&
|
|
47
|
+
typeof SharedArrayBuffer !== "undefined" &&
|
|
48
|
+
value.buffer instanceof SharedArrayBuffer) {
|
|
49
|
+
const copy = Uint8Array.from(new Uint8Array(value.buffer, value.byteOffset, value.byteLength));
|
|
50
|
+
seen.set(value, copy);
|
|
51
|
+
return copy;
|
|
52
|
+
}
|
|
53
|
+
seen.set(value, value);
|
|
54
|
+
if (value instanceof Map) {
|
|
55
|
+
const entries = [...value.entries()];
|
|
56
|
+
value.clear();
|
|
57
|
+
for (const [key, entryValue] of entries) {
|
|
58
|
+
value.set(removeSharedMemory(key, seen), removeSharedMemory(entryValue, seen));
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
if (value instanceof Set) {
|
|
63
|
+
const entries = [...value.values()];
|
|
64
|
+
value.clear();
|
|
65
|
+
for (const entryValue of entries) {
|
|
66
|
+
value.add(removeSharedMemory(entryValue, seen));
|
|
67
|
+
}
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
71
|
+
Reflect.set(value, key, removeSharedMemory(Reflect.get(value, key), seen));
|
|
72
|
+
}
|
|
73
|
+
return value;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Reject a history limit that cannot bound anything.
|
|
77
|
+
*
|
|
78
|
+
* A negative limit used to read as "unbounded" and a fractional one evicted a
|
|
79
|
+
* fractional number of records, so a typo silently disabled the cap instead of
|
|
80
|
+
* failing. `Number.isInteger` also rejects NaN and Infinity. `0` stays valid
|
|
81
|
+
* and keeps meaning "history disabled".
|
|
82
|
+
*/
|
|
83
|
+
function assertValidHistoryLimit(limit) {
|
|
84
|
+
if (limit === undefined)
|
|
85
|
+
return;
|
|
86
|
+
if (!Number.isInteger(limit) || limit < 0) {
|
|
87
|
+
throw new SchmockError(`Invalid maxHistorySize: ${String(limit)}. Expected a non-negative integer (0 disables history).`, "INVALID_CONFIG", { maxHistorySize: limit });
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Header names whose VALUE is replaced in debug logs. The name is kept so a log
|
|
92
|
+
* still shows the header was present; only the credential is hidden. Matches
|
|
93
|
+
* the set the CLI already masks.
|
|
94
|
+
*/
|
|
95
|
+
const REDACTED_HEADER_NAMES = new Set([
|
|
96
|
+
"authorization",
|
|
97
|
+
"proxy-authorization",
|
|
98
|
+
"cookie",
|
|
99
|
+
"set-cookie",
|
|
100
|
+
"x-api-key",
|
|
101
|
+
"x-auth-token",
|
|
102
|
+
"x-schmock-admin-token",
|
|
103
|
+
]);
|
|
104
|
+
const REDACTED_HEADER_VALUE = "[redacted]";
|
|
105
|
+
/**
|
|
106
|
+
* Copy-on-write redaction: the input record is handed on to plugins, history
|
|
107
|
+
* and transports, so it must never be mutated. When nothing is sensitive the
|
|
108
|
+
* original object is returned unchanged.
|
|
109
|
+
*/
|
|
110
|
+
function redactSensitiveHeaders(headers) {
|
|
111
|
+
let redacted;
|
|
112
|
+
for (const name of Object.keys(headers)) {
|
|
113
|
+
if (!REDACTED_HEADER_NAMES.has(name.toLowerCase()))
|
|
114
|
+
continue;
|
|
115
|
+
redacted ??= { ...headers };
|
|
116
|
+
redacted[name] = REDACTED_HEADER_VALUE;
|
|
117
|
+
}
|
|
118
|
+
return redacted ?? headers;
|
|
119
|
+
}
|
|
120
|
+
function snapshotHistoryValue(value) {
|
|
121
|
+
try {
|
|
122
|
+
return removeSharedMemory(structuredClone(value));
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return unavailableHistoryValue(value);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
10
128
|
/**
|
|
11
129
|
* Debug logger that respects debug mode configuration
|
|
12
130
|
*/
|
|
@@ -44,7 +162,6 @@ class DebugLogger {
|
|
|
44
162
|
* @internal
|
|
45
163
|
*/
|
|
46
164
|
export class CallableMockInstance {
|
|
47
|
-
globalConfig;
|
|
48
165
|
routes = [];
|
|
49
166
|
staticRoutes = new Map();
|
|
50
167
|
plugins = [];
|
|
@@ -52,12 +169,21 @@ export class CallableMockInstance {
|
|
|
52
169
|
requestHistory = [];
|
|
53
170
|
callableRef;
|
|
54
171
|
server;
|
|
55
|
-
|
|
56
|
-
|
|
172
|
+
pendingServerStart;
|
|
173
|
+
serverCloseBarrier;
|
|
174
|
+
interceptHandles = new Set();
|
|
175
|
+
requestGeneration = { activeAdmissions: 0 };
|
|
176
|
+
historyGeneration = Symbol("schmock.history.generation");
|
|
177
|
+
interceptOwner = Symbol("schmock.intercept.owner");
|
|
178
|
+
globalConfig;
|
|
57
179
|
// biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
|
|
58
180
|
listeners = new Map();
|
|
59
181
|
constructor(globalConfig = {}) {
|
|
60
|
-
|
|
182
|
+
assertValidHistoryLimit(globalConfig.maxHistorySize);
|
|
183
|
+
this.globalConfig = {
|
|
184
|
+
...globalConfig,
|
|
185
|
+
state: globalConfig.state ?? {},
|
|
186
|
+
};
|
|
61
187
|
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
62
188
|
if (globalConfig.debug) {
|
|
63
189
|
this.logger.log("config", "Debug mode enabled");
|
|
@@ -140,6 +266,82 @@ export class CallableMockInstance {
|
|
|
140
266
|
this.callableRef = ref;
|
|
141
267
|
}
|
|
142
268
|
pipe(plugin) {
|
|
269
|
+
if (plugin.install && this.callableRef) {
|
|
270
|
+
const previousRoutes = this.routes;
|
|
271
|
+
const previousStaticRoutes = this.staticRoutes;
|
|
272
|
+
this.routes = previousRoutes.slice();
|
|
273
|
+
this.staticRoutes = new Map(previousStaticRoutes);
|
|
274
|
+
let installActive = true;
|
|
275
|
+
let installFacade;
|
|
276
|
+
const requireInstallScope = () => {
|
|
277
|
+
if (installActive)
|
|
278
|
+
return;
|
|
279
|
+
throw new SchmockError(`Plugin "${plugin.name}" used its install instance outside install()`, "PLUGIN_INSTALL_SCOPE_EXPIRED", { plugin: plugin.name });
|
|
280
|
+
};
|
|
281
|
+
const rejectInstallOperation = (operation) => {
|
|
282
|
+
requireInstallScope();
|
|
283
|
+
throw new SchmockError(`Plugin "${plugin.name}" cannot call ${operation} during install()`, "PLUGIN_INSTALL_OPERATION_UNSUPPORTED", { operation, plugin: plugin.name });
|
|
284
|
+
};
|
|
285
|
+
const registerRoute = (route, generator, config = {}) => {
|
|
286
|
+
requireInstallScope();
|
|
287
|
+
this.defineRoute(route, generator, config);
|
|
288
|
+
return installFacade;
|
|
289
|
+
};
|
|
290
|
+
installFacade = Object.assign(registerRoute, {
|
|
291
|
+
pipe: () => rejectInstallOperation("pipe()"),
|
|
292
|
+
handle: () => rejectInstallOperation("handle()"),
|
|
293
|
+
history: (method, path) => {
|
|
294
|
+
requireInstallScope();
|
|
295
|
+
return this.history(method, path);
|
|
296
|
+
},
|
|
297
|
+
called: (method, path) => {
|
|
298
|
+
requireInstallScope();
|
|
299
|
+
return this.called(method, path);
|
|
300
|
+
},
|
|
301
|
+
callCount: (method, path) => {
|
|
302
|
+
requireInstallScope();
|
|
303
|
+
return this.callCount(method, path);
|
|
304
|
+
},
|
|
305
|
+
lastRequest: (method, path) => {
|
|
306
|
+
requireInstallScope();
|
|
307
|
+
return this.lastRequest(method, path);
|
|
308
|
+
},
|
|
309
|
+
reset: () => rejectInstallOperation("reset()"),
|
|
310
|
+
resetHistory: () => rejectInstallOperation("resetHistory()"),
|
|
311
|
+
resetState: () => rejectInstallOperation("resetState()"),
|
|
312
|
+
on: () => rejectInstallOperation("on()"),
|
|
313
|
+
off: () => rejectInstallOperation("off()"),
|
|
314
|
+
getRoutes: () => {
|
|
315
|
+
requireInstallScope();
|
|
316
|
+
return this.getRoutes();
|
|
317
|
+
},
|
|
318
|
+
getState: () => {
|
|
319
|
+
requireInstallScope();
|
|
320
|
+
return this.getState();
|
|
321
|
+
},
|
|
322
|
+
listen: () => rejectInstallOperation("listen()"),
|
|
323
|
+
close: () => rejectInstallOperation("close()"),
|
|
324
|
+
intercept: () => rejectInstallOperation("intercept()"),
|
|
325
|
+
});
|
|
326
|
+
try {
|
|
327
|
+
const installResult = plugin.install(installFacade);
|
|
328
|
+
installActive = false;
|
|
329
|
+
if (isThenable(installResult)) {
|
|
330
|
+
void Promise.resolve(installResult).catch((error) => {
|
|
331
|
+
this.logger.log("plugin", `Rejected async install for ${plugin.name}: ${errorMessage(error)}`);
|
|
332
|
+
});
|
|
333
|
+
throw new SchmockError(`Plugin "${plugin.name}" returned a Promise from install()`, "PLUGIN_ASYNC_INSTALL_UNSUPPORTED", { plugin: plugin.name });
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
catch (error) {
|
|
337
|
+
this.routes = previousRoutes;
|
|
338
|
+
this.staticRoutes = previousStaticRoutes;
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
finally {
|
|
342
|
+
installActive = false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
143
345
|
this.plugins.push(plugin);
|
|
144
346
|
this.logger.log("plugin", `Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`, {
|
|
145
347
|
name: plugin.name,
|
|
@@ -147,52 +349,79 @@ export class CallableMockInstance {
|
|
|
147
349
|
hasProcess: typeof plugin.process === "function",
|
|
148
350
|
hasOnError: typeof plugin.onError === "function",
|
|
149
351
|
});
|
|
150
|
-
if (plugin.install && this.callableRef) {
|
|
151
|
-
plugin.install(this.callableRef);
|
|
152
|
-
}
|
|
153
352
|
return this;
|
|
154
353
|
}
|
|
354
|
+
uninstallPlugins(plugins) {
|
|
355
|
+
for (let index = plugins.length - 1; index >= 0; index -= 1) {
|
|
356
|
+
const plugin = plugins[index];
|
|
357
|
+
if (!plugin.uninstall || !this.callableRef)
|
|
358
|
+
continue;
|
|
359
|
+
try {
|
|
360
|
+
const uninstallResult = plugin.uninstall(this.callableRef);
|
|
361
|
+
if (isThenable(uninstallResult)) {
|
|
362
|
+
void Promise.resolve(uninstallResult).catch((error) => {
|
|
363
|
+
this.logger.log("plugin", `Async uninstall for ${plugin.name} failed: ${errorMessage(error)}`);
|
|
364
|
+
});
|
|
365
|
+
this.logger.log("plugin", `Plugin ${plugin.name} returned an unsupported Promise from uninstall()`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
catch (error) {
|
|
369
|
+
this.logger.log("plugin", `Plugin ${plugin.name} uninstall failed: ${errorMessage(error)}`);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
155
373
|
// ===== Request Spy / History API =====
|
|
374
|
+
cloneRecord(r) {
|
|
375
|
+
return {
|
|
376
|
+
method: r.method,
|
|
377
|
+
path: r.path,
|
|
378
|
+
params: { ...r.params },
|
|
379
|
+
query: { ...r.query },
|
|
380
|
+
headers: { ...r.headers },
|
|
381
|
+
body: snapshotHistoryValue(r.body),
|
|
382
|
+
timestamp: r.timestamp,
|
|
383
|
+
response: {
|
|
384
|
+
status: r.response.status,
|
|
385
|
+
body: snapshotHistoryValue(r.response.body),
|
|
386
|
+
},
|
|
387
|
+
};
|
|
388
|
+
}
|
|
156
389
|
/**
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
160
|
-
*
|
|
390
|
+
* History stores the canonical request path — percent-encoded and
|
|
391
|
+
* trailing-slash-normalized exactly as `handle()` produced it — so a spy
|
|
392
|
+
* filter must be put into the same form before it is compared, or the very
|
|
393
|
+
* string the caller passed to `handle()` would not match its own record.
|
|
394
|
+
* `canonicalizePath` is idempotent, so an already-encoded filter keeps
|
|
395
|
+
* matching and both spellings work.
|
|
161
396
|
*/
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
catch {
|
|
167
|
-
return {
|
|
168
|
-
...r,
|
|
169
|
-
response: { ...r.response },
|
|
170
|
-
};
|
|
171
|
-
}
|
|
397
|
+
#historyMatcher(method, path) {
|
|
398
|
+
const wanted = path === undefined ? undefined : normalizePath(canonicalizePath(path));
|
|
399
|
+
return (r) => (!method || r.method === method) && (!wanted || r.path === wanted);
|
|
172
400
|
}
|
|
173
401
|
history(method, path) {
|
|
174
402
|
if (method || path) {
|
|
175
403
|
return this.requestHistory
|
|
176
|
-
.filter((
|
|
404
|
+
.filter(this.#historyMatcher(method, path))
|
|
177
405
|
.map((r) => this.cloneRecord(r));
|
|
178
406
|
}
|
|
179
407
|
return this.requestHistory.map((r) => this.cloneRecord(r));
|
|
180
408
|
}
|
|
181
409
|
called(method, path) {
|
|
182
410
|
if (method || path) {
|
|
183
|
-
return this.requestHistory.some((
|
|
411
|
+
return this.requestHistory.some(this.#historyMatcher(method, path));
|
|
184
412
|
}
|
|
185
413
|
return this.requestHistory.length > 0;
|
|
186
414
|
}
|
|
187
415
|
callCount(method, path) {
|
|
188
416
|
if (method || path) {
|
|
189
|
-
return this.requestHistory.filter((
|
|
417
|
+
return this.requestHistory.filter(this.#historyMatcher(method, path))
|
|
418
|
+
.length;
|
|
190
419
|
}
|
|
191
420
|
return this.requestHistory.length;
|
|
192
421
|
}
|
|
193
422
|
lastRequest(method, path) {
|
|
194
423
|
if (method || path) {
|
|
195
|
-
const filtered = this.requestHistory.filter((
|
|
424
|
+
const filtered = this.requestHistory.filter(this.#historyMatcher(method, path));
|
|
196
425
|
const last = filtered[filtered.length - 1];
|
|
197
426
|
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
198
427
|
return last ? this.cloneRecord(last) : undefined;
|
|
@@ -228,192 +457,427 @@ export class CallableMockInstance {
|
|
|
228
457
|
}
|
|
229
458
|
emit(event, data) {
|
|
230
459
|
const set = this.listeners.get(event);
|
|
231
|
-
if (set)
|
|
232
|
-
|
|
233
|
-
|
|
460
|
+
if (!set)
|
|
461
|
+
return;
|
|
462
|
+
const snapshot = { ...data };
|
|
463
|
+
if ("headers" in data) {
|
|
464
|
+
snapshot.headers = Object.freeze({ ...data.headers });
|
|
465
|
+
}
|
|
466
|
+
if ("params" in data) {
|
|
467
|
+
snapshot.params = Object.freeze({ ...data.params });
|
|
468
|
+
}
|
|
469
|
+
const eventData = Object.freeze(snapshot);
|
|
470
|
+
for (const listener of [...set]) {
|
|
471
|
+
try {
|
|
472
|
+
const listenerResult = listener(eventData);
|
|
473
|
+
if (isThenable(listenerResult)) {
|
|
474
|
+
void Promise.resolve(listenerResult).catch((error) => {
|
|
475
|
+
this.logger.log("event", `${event} listener rejected: ${errorMessage(error)}`);
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
catch (error) {
|
|
480
|
+
this.logger.log("event", `${event} listener failed: ${errorMessage(error)}`);
|
|
234
481
|
}
|
|
235
482
|
}
|
|
236
483
|
}
|
|
237
484
|
// ===== Reset / Lifecycle =====
|
|
238
485
|
reset() {
|
|
239
|
-
this.
|
|
240
|
-
this.
|
|
486
|
+
const retiredGeneration = this.requestGeneration;
|
|
487
|
+
this.requestGeneration = { activeAdmissions: 0 };
|
|
488
|
+
this.historyGeneration = Symbol("schmock.history.generation");
|
|
241
489
|
this.close();
|
|
490
|
+
const installedPlugins = this.plugins;
|
|
491
|
+
this.plugins = [];
|
|
492
|
+
this.#retireRequestGeneration(retiredGeneration, installedPlugins);
|
|
242
493
|
this.routes = [];
|
|
243
494
|
this.staticRoutes.clear();
|
|
244
|
-
this.plugins = [];
|
|
245
495
|
this.requestHistory = [];
|
|
246
496
|
this.listeners.clear();
|
|
247
|
-
|
|
248
|
-
// reference — this avoids mutating the external state object passed by the user
|
|
249
|
-
if (this.globalConfig.state) {
|
|
250
|
-
this.globalConfig.state = {};
|
|
251
|
-
}
|
|
497
|
+
this.globalConfig.state = {};
|
|
252
498
|
this.logger.log("lifecycle", "Mock fully reset");
|
|
253
499
|
}
|
|
254
500
|
resetHistory() {
|
|
501
|
+
this.historyGeneration = Symbol("schmock.history.generation");
|
|
255
502
|
this.requestHistory = [];
|
|
256
503
|
this.logger.log("lifecycle", "Request history cleared");
|
|
257
504
|
}
|
|
258
505
|
resetState() {
|
|
259
|
-
|
|
260
|
-
// reference — this avoids mutating the external state object passed by the user
|
|
261
|
-
if (this.globalConfig.state) {
|
|
262
|
-
this.globalConfig.state = {};
|
|
263
|
-
}
|
|
506
|
+
this.globalConfig.state = {};
|
|
264
507
|
this.logger.log("lifecycle", "State cleared");
|
|
265
508
|
}
|
|
509
|
+
#captureRequestAdmission() {
|
|
510
|
+
const requestGeneration = this.requestGeneration;
|
|
511
|
+
requestGeneration.activeAdmissions += 1;
|
|
512
|
+
return {
|
|
513
|
+
requestGeneration,
|
|
514
|
+
historyGeneration: this.historyGeneration,
|
|
515
|
+
plugins: this.plugins.slice(),
|
|
516
|
+
routes: this.routes.slice(),
|
|
517
|
+
staticRoutes: new Map(this.staticRoutes),
|
|
518
|
+
state: this.globalConfig.state,
|
|
519
|
+
namespace: this.globalConfig.namespace,
|
|
520
|
+
globalDelay: this.globalConfig.delay,
|
|
521
|
+
maxHistorySize: this.globalConfig.maxHistorySize,
|
|
522
|
+
released: false,
|
|
523
|
+
};
|
|
524
|
+
}
|
|
525
|
+
#releaseRequestAdmission(admission) {
|
|
526
|
+
if (admission.released)
|
|
527
|
+
return;
|
|
528
|
+
admission.released = true;
|
|
529
|
+
const generation = admission.requestGeneration;
|
|
530
|
+
generation.activeAdmissions -= 1;
|
|
531
|
+
if (generation.activeAdmissions === 0 &&
|
|
532
|
+
generation.retiredPlugins !== undefined) {
|
|
533
|
+
const plugins = generation.retiredPlugins;
|
|
534
|
+
generation.retiredPlugins = undefined;
|
|
535
|
+
this.uninstallPlugins(plugins);
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
#retireRequestGeneration(generation, plugins) {
|
|
539
|
+
generation.retiredPlugins = plugins;
|
|
540
|
+
if (generation.activeAdmissions === 0) {
|
|
541
|
+
generation.retiredPlugins = undefined;
|
|
542
|
+
this.uninstallPlugins(plugins);
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
createRequestAdmission() {
|
|
546
|
+
const admission = this.#captureRequestAdmission();
|
|
547
|
+
return {
|
|
548
|
+
handle: (method, path, options) => this.handle(method, path, options, admission),
|
|
549
|
+
release: () => this.#releaseRequestAdmission(admission),
|
|
550
|
+
};
|
|
551
|
+
}
|
|
266
552
|
// ===== Standalone Server =====
|
|
267
553
|
listen(port = 0, hostname = "127.0.0.1") {
|
|
268
|
-
if (this.server) {
|
|
554
|
+
if (this.server || this.pendingServerStart) {
|
|
269
555
|
throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
|
|
270
556
|
}
|
|
271
|
-
|
|
272
|
-
|
|
557
|
+
let resolveStart = (_info) => { };
|
|
558
|
+
let rejectStart = (_error) => { };
|
|
559
|
+
const startPromise = new Promise((resolve, reject) => {
|
|
560
|
+
resolveStart = resolve;
|
|
561
|
+
rejectStart = reject;
|
|
562
|
+
});
|
|
563
|
+
const operation = {
|
|
564
|
+
token: Symbol("schmock.server.start"),
|
|
565
|
+
port,
|
|
566
|
+
hostname,
|
|
567
|
+
resolve: resolveStart,
|
|
568
|
+
reject: rejectStart,
|
|
569
|
+
settled: false,
|
|
570
|
+
};
|
|
571
|
+
this.pendingServerStart = operation;
|
|
572
|
+
const closeBarrier = this.serverCloseBarrier ?? Promise.resolve();
|
|
573
|
+
void closeBarrier
|
|
574
|
+
// Lazy-load node:http so browser bundles never pull it in. See issue #395.
|
|
575
|
+
.then(() => import("node:http"))
|
|
576
|
+
.then(({ createServer }) => {
|
|
577
|
+
if (!this.#ownsServerStart(operation))
|
|
578
|
+
return;
|
|
579
|
+
this.#startHttpServer(operation, createServer);
|
|
580
|
+
})
|
|
581
|
+
.catch((error) => {
|
|
582
|
+
this.#rejectServerStart(operation, error);
|
|
583
|
+
});
|
|
584
|
+
return startPromise;
|
|
585
|
+
}
|
|
586
|
+
#ownsServerStart(operation) {
|
|
587
|
+
return this.pendingServerStart === operation && !operation.settled;
|
|
273
588
|
}
|
|
274
|
-
#startHttpServer(
|
|
589
|
+
#startHttpServer(operation, createServer) {
|
|
275
590
|
const httpServer = createServer((req, res) => {
|
|
591
|
+
const admittedRequest = this.createRequestAdmission();
|
|
592
|
+
const abortController = new AbortController();
|
|
593
|
+
const abortRequest = () => abortController.abort();
|
|
594
|
+
req.once("aborted", abortRequest);
|
|
595
|
+
res.once("close", abortRequest);
|
|
596
|
+
let requestMethod = req.method?.toUpperCase() === "HEAD" ? "HEAD" : "GET";
|
|
276
597
|
const handleRequest = async () => {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
headers
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
598
|
+
try {
|
|
599
|
+
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
600
|
+
const method = toHttpMethod(req.method ?? "GET");
|
|
601
|
+
requestMethod = method;
|
|
602
|
+
const path = url.pathname;
|
|
603
|
+
const headers = parseNodeHeaders(req);
|
|
604
|
+
const query = parseNodeQuery(url);
|
|
605
|
+
const body = await collectBody(req, headers);
|
|
606
|
+
const schmockResponse = await admittedRequest.handle(method, path, {
|
|
607
|
+
headers,
|
|
608
|
+
body,
|
|
609
|
+
query,
|
|
610
|
+
signal: abortController.signal,
|
|
611
|
+
});
|
|
612
|
+
writeSchmockResponse(res, schmockResponse);
|
|
613
|
+
}
|
|
614
|
+
finally {
|
|
615
|
+
req.off("aborted", abortRequest);
|
|
616
|
+
res.off("close", abortRequest);
|
|
617
|
+
admittedRequest.release();
|
|
618
|
+
}
|
|
289
619
|
};
|
|
290
620
|
handleRequest().catch((error) => {
|
|
291
|
-
|
|
292
|
-
|
|
621
|
+
// A failing error-response write must never escape this handler as an
|
|
622
|
+
// unhandled rejection: destroy the socket so the client is not left
|
|
623
|
+
// hanging on a response that will never arrive.
|
|
624
|
+
try {
|
|
625
|
+
const ingressError = error instanceof HttpIngressError ? error : undefined;
|
|
626
|
+
const status = ingressError?.status ?? 500;
|
|
627
|
+
const code = ingressError?.code ?? "SERVER_ERROR";
|
|
628
|
+
if (!res.headersSent && !res.writableEnded) {
|
|
629
|
+
if (ingressError)
|
|
630
|
+
res.shouldKeepAlive = false;
|
|
631
|
+
// `shouldKeepAlive = false` alone emits no Connection header when
|
|
632
|
+
// writeHead is given a header object, so the announcement has to be
|
|
633
|
+
// explicit. It travels on the transport's own header channel rather
|
|
634
|
+
// than on the response: normalizeResponse strips hop-by-hop headers
|
|
635
|
+
// from everything a route produces.
|
|
636
|
+
const transportHeaders = ingressError
|
|
637
|
+
? { connection: "close" }
|
|
638
|
+
: undefined;
|
|
639
|
+
const response = normalizeResponse({
|
|
640
|
+
status,
|
|
641
|
+
body: {
|
|
642
|
+
error: error instanceof Error
|
|
643
|
+
? error.message
|
|
644
|
+
: "Internal Server Error",
|
|
645
|
+
code,
|
|
646
|
+
},
|
|
647
|
+
headers: { "content-type": "application/json" },
|
|
648
|
+
}, requestMethod);
|
|
649
|
+
if (ingressError?.status === 413) {
|
|
650
|
+
writeRejectedSchmockResponse(req, res, response, transportHeaders);
|
|
651
|
+
}
|
|
652
|
+
else {
|
|
653
|
+
writeSchmockResponse(res, response, transportHeaders);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
else if (!res.writableEnded) {
|
|
657
|
+
res.end();
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
res.destroy();
|
|
293
662
|
}
|
|
294
|
-
res.end(JSON.stringify({
|
|
295
|
-
error: error instanceof Error ? error.message : "Internal Server Error",
|
|
296
|
-
code: "SERVER_ERROR",
|
|
297
|
-
}));
|
|
298
663
|
});
|
|
299
664
|
});
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
reject(error);
|
|
308
|
-
};
|
|
309
|
-
httpServer.once("error", handleStartupError);
|
|
310
|
-
httpServer.listen(port, hostname, () => {
|
|
665
|
+
operation.server = httpServer;
|
|
666
|
+
const handleStartupError = (error) => {
|
|
667
|
+
this.#rejectServerStart(operation, error);
|
|
668
|
+
};
|
|
669
|
+
httpServer.once("error", handleStartupError);
|
|
670
|
+
try {
|
|
671
|
+
httpServer.listen(operation.port, operation.hostname, () => {
|
|
311
672
|
httpServer.off("error", handleStartupError);
|
|
673
|
+
if (!this.#ownsServerStart(operation)) {
|
|
674
|
+
this.#beginServerClose(httpServer);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
312
677
|
const addr = httpServer.address();
|
|
313
|
-
const actualPort = addr !== null && typeof addr === "object"
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
678
|
+
const actualPort = addr !== null && typeof addr === "object"
|
|
679
|
+
? addr.port
|
|
680
|
+
: operation.port;
|
|
681
|
+
const info = { port: actualPort, hostname: operation.hostname };
|
|
682
|
+
operation.settled = true;
|
|
683
|
+
this.pendingServerStart = undefined;
|
|
684
|
+
this.server = httpServer;
|
|
685
|
+
this.logger.log("server", `Listening on ${operation.hostname}:${actualPort}`);
|
|
686
|
+
operation.resolve(info);
|
|
317
687
|
});
|
|
688
|
+
}
|
|
689
|
+
catch (error) {
|
|
690
|
+
httpServer.off("error", handleStartupError);
|
|
691
|
+
this.#rejectServerStart(operation, error);
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
#rejectServerStart(operation, error) {
|
|
695
|
+
if (operation.settled)
|
|
696
|
+
return;
|
|
697
|
+
operation.settled = true;
|
|
698
|
+
if (this.pendingServerStart === operation) {
|
|
699
|
+
this.pendingServerStart = undefined;
|
|
700
|
+
}
|
|
701
|
+
if (operation.server) {
|
|
702
|
+
this.#beginServerClose(operation.server);
|
|
703
|
+
}
|
|
704
|
+
operation.reject(error);
|
|
705
|
+
}
|
|
706
|
+
#cancelServerStart() {
|
|
707
|
+
const operation = this.pendingServerStart;
|
|
708
|
+
if (!operation)
|
|
709
|
+
return;
|
|
710
|
+
this.#rejectServerStart(operation, new SchmockError("Server start was cancelled", "SERVER_START_CANCELLED"));
|
|
711
|
+
}
|
|
712
|
+
#beginServerClose(server) {
|
|
713
|
+
const closePromise = new Promise((resolve) => {
|
|
714
|
+
try {
|
|
715
|
+
server.close(() => resolve());
|
|
716
|
+
}
|
|
717
|
+
catch {
|
|
718
|
+
resolve();
|
|
719
|
+
}
|
|
720
|
+
});
|
|
721
|
+
try {
|
|
722
|
+
server.closeAllConnections();
|
|
723
|
+
}
|
|
724
|
+
catch {
|
|
725
|
+
// A not-yet-listening server has no connections to close.
|
|
726
|
+
}
|
|
727
|
+
const previousBarrier = this.serverCloseBarrier ?? Promise.resolve();
|
|
728
|
+
const combinedBarrier = Promise.all([previousBarrier, closePromise]).then(() => undefined);
|
|
729
|
+
this.serverCloseBarrier = combinedBarrier;
|
|
730
|
+
void combinedBarrier.finally(() => {
|
|
731
|
+
if (this.serverCloseBarrier === combinedBarrier) {
|
|
732
|
+
this.serverCloseBarrier = undefined;
|
|
733
|
+
}
|
|
318
734
|
});
|
|
319
735
|
}
|
|
320
736
|
close() {
|
|
321
|
-
|
|
737
|
+
this.#cancelServerStart();
|
|
738
|
+
const server = this.server;
|
|
739
|
+
if (!server)
|
|
322
740
|
return;
|
|
323
|
-
}
|
|
324
|
-
this.server.closeAllConnections();
|
|
325
|
-
this.server.close();
|
|
326
741
|
this.server = undefined;
|
|
327
|
-
this
|
|
742
|
+
this.#beginServerClose(server);
|
|
328
743
|
this.logger.log("server", "Server stopped");
|
|
329
744
|
}
|
|
330
745
|
// ===== Fetch Interceptor =====
|
|
331
746
|
intercept(options) {
|
|
332
|
-
|
|
333
|
-
|
|
747
|
+
// Ownership is a lease, not a lock: nested providers, separate roots, and
|
|
748
|
+
// a manual intercept() alongside an adapter each get their own registry
|
|
749
|
+
// slot with their own options, released independently. The owner symbol
|
|
750
|
+
// keeps them one mock for dispatch, so a single request reaches handle()
|
|
751
|
+
// once no matter how many leases this instance holds.
|
|
752
|
+
const lease = createFetchInterceptor((method, path, opts) => this.handle(method, path, opts), options, () => this.createRequestAdmission(), this.interceptOwner);
|
|
753
|
+
const handle = {
|
|
754
|
+
restore: () => {
|
|
755
|
+
lease.restore();
|
|
756
|
+
if (this.interceptHandles.delete(handle)) {
|
|
757
|
+
this.logger.log("lifecycle", `Interception lease released (${this.interceptHandles.size} still held)`);
|
|
758
|
+
}
|
|
759
|
+
},
|
|
760
|
+
update: (nextOptions) => {
|
|
761
|
+
lease.update(nextOptions);
|
|
762
|
+
},
|
|
763
|
+
get active() {
|
|
764
|
+
return lease.active;
|
|
765
|
+
},
|
|
766
|
+
};
|
|
767
|
+
this.interceptHandles.add(handle);
|
|
768
|
+
this.logger.log("lifecycle", `Interception lease acquired (${this.interceptHandles.size} held)`);
|
|
769
|
+
return handle;
|
|
770
|
+
}
|
|
771
|
+
async handle(method, path, options, admission) {
|
|
772
|
+
const requestAdmission = admission ?? this.#captureRequestAdmission();
|
|
773
|
+
try {
|
|
774
|
+
return await this.#handleAdmittedRequest(method, path, options, requestAdmission);
|
|
775
|
+
}
|
|
776
|
+
finally {
|
|
777
|
+
this.#releaseRequestAdmission(requestAdmission);
|
|
334
778
|
}
|
|
335
|
-
this.interceptHandle = createFetchInterceptor((method, path, opts) => this.handle(method, path, opts), options);
|
|
336
|
-
return this.interceptHandle;
|
|
337
779
|
}
|
|
338
|
-
async
|
|
780
|
+
async #handleAdmittedRequest(method, requestedPath, options, admission) {
|
|
781
|
+
// Canonicalize before anything observes the path: a transport hands over an
|
|
782
|
+
// already-encoded `url.pathname` while a direct handle() caller may type
|
|
783
|
+
// literal unicode, and every lifecycle event, log line and 404 message must
|
|
784
|
+
// report the same spelling.
|
|
785
|
+
const path = canonicalizePath(requestedPath);
|
|
786
|
+
const requestGeneration = admission.requestGeneration;
|
|
787
|
+
const historyGeneration = admission.historyGeneration;
|
|
788
|
+
const requestPlugins = admission.plugins;
|
|
789
|
+
const requestRoutes = admission.routes;
|
|
790
|
+
const requestStaticRoutes = admission.staticRoutes;
|
|
791
|
+
const requestState = admission.state;
|
|
792
|
+
const namespace = admission.namespace;
|
|
793
|
+
const globalDelay = admission.globalDelay;
|
|
794
|
+
const maxHistorySize = admission.maxHistorySize;
|
|
795
|
+
const signal = options?.signal;
|
|
796
|
+
throwIfAborted(signal);
|
|
339
797
|
const handleStart = performance.now();
|
|
340
798
|
const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
|
|
341
|
-
const reqQuery = options?.query
|
|
342
|
-
const reqHeaders = options?.headers
|
|
799
|
+
const reqQuery = { ...(options?.query ?? {}) };
|
|
800
|
+
const reqHeaders = { ...(options?.headers ?? {}) };
|
|
801
|
+
const requestBody = options?.body;
|
|
343
802
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
344
|
-
headers: reqHeaders,
|
|
803
|
+
headers: redactSensitiveHeaders(reqHeaders),
|
|
345
804
|
query: reqQuery,
|
|
346
|
-
|
|
805
|
+
// Presence, not truthiness: "", 0 and false are bodies too.
|
|
806
|
+
bodyType: options !== undefined && "body" in options && options.body !== undefined
|
|
807
|
+
? typeof options.body
|
|
808
|
+
: "none",
|
|
347
809
|
});
|
|
348
810
|
this.logger.time(`request-${requestId}`);
|
|
349
|
-
this.
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
811
|
+
if (this.requestGeneration === requestGeneration) {
|
|
812
|
+
this.emit("request:start", {
|
|
813
|
+
method,
|
|
814
|
+
path,
|
|
815
|
+
headers: reqHeaders,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
// Hoisted so the catch block can finalize a matched request the same way
|
|
819
|
+
// the success path does — same delay override, same history record.
|
|
820
|
+
let requestPath = path;
|
|
821
|
+
let matchedRoute;
|
|
822
|
+
let params = {};
|
|
354
823
|
try {
|
|
355
824
|
// Apply namespace if configured
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
const namespace = this.globalConfig.namespace.startsWith("/")
|
|
359
|
-
? this.globalConfig.namespace
|
|
360
|
-
: `/${this.globalConfig.namespace}`;
|
|
825
|
+
if (namespace && namespace !== "/") {
|
|
826
|
+
const normalizedNamespace = canonicalizePath(namespace.startsWith("/") ? namespace : `/${namespace}`);
|
|
361
827
|
const pathToCheck = path.startsWith("/") ? path : `/${path}`;
|
|
362
828
|
// Check if path starts with namespace
|
|
363
829
|
// handle both "/api/users" (starts with /api) and "/api" (exact match)
|
|
364
830
|
// but NOT "/apiv2" (prefix match but wrong segment)
|
|
365
|
-
const isMatch = pathToCheck ===
|
|
366
|
-
pathToCheck.startsWith(
|
|
831
|
+
const isMatch = pathToCheck === normalizedNamespace ||
|
|
832
|
+
pathToCheck.startsWith(normalizedNamespace.endsWith("/")
|
|
833
|
+
? normalizedNamespace
|
|
834
|
+
: `${normalizedNamespace}/`);
|
|
367
835
|
if (!isMatch) {
|
|
368
|
-
this.logger.log("route", `[${requestId}] Path doesn't match namespace ${
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
body: { error: error.message, code: error.code },
|
|
373
|
-
headers: { "content-type": "application/json" },
|
|
374
|
-
};
|
|
375
|
-
this.emit("request:end", {
|
|
836
|
+
this.logger.log("route", `[${requestId}] Path doesn't match namespace ${normalizedNamespace}`);
|
|
837
|
+
// A request outside the namespace is a route miss like any other, so
|
|
838
|
+
// it reports one instead of silently ending.
|
|
839
|
+
return this.#finalizeMiss({
|
|
376
840
|
method,
|
|
377
841
|
path,
|
|
378
|
-
|
|
379
|
-
|
|
842
|
+
requestId,
|
|
843
|
+
handleStart,
|
|
844
|
+
requestGeneration,
|
|
380
845
|
});
|
|
381
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
382
|
-
return response;
|
|
383
846
|
}
|
|
384
847
|
// Remove namespace prefix, ensuring we always start with /
|
|
385
|
-
const stripped = pathToCheck.slice(
|
|
848
|
+
const stripped = pathToCheck.slice(normalizedNamespace.length);
|
|
386
849
|
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
387
850
|
}
|
|
851
|
+
// One trailing-slash normalization for the whole request: route lookup
|
|
852
|
+
// and parameter extraction must see the identical string, or a request
|
|
853
|
+
// could match a route and then capture no parameters.
|
|
854
|
+
requestPath = normalizePath(requestPath);
|
|
388
855
|
// Find matching route
|
|
389
|
-
|
|
856
|
+
matchedRoute = findRoute(method, requestPath, requestStaticRoutes, requestRoutes);
|
|
390
857
|
if (!matchedRoute) {
|
|
391
858
|
this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
|
|
392
|
-
this
|
|
393
|
-
const error = new RouteNotFoundError(method, path);
|
|
394
|
-
const response = {
|
|
395
|
-
status: 404,
|
|
396
|
-
body: { error: error.message, code: error.code },
|
|
397
|
-
headers: { "content-type": "application/json" },
|
|
398
|
-
};
|
|
399
|
-
this.emit("request:end", {
|
|
859
|
+
return this.#finalizeMiss({
|
|
400
860
|
method,
|
|
401
|
-
path
|
|
402
|
-
|
|
403
|
-
|
|
861
|
+
path,
|
|
862
|
+
requestId,
|
|
863
|
+
handleStart,
|
|
864
|
+
requestGeneration,
|
|
404
865
|
});
|
|
405
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
406
|
-
return response;
|
|
407
866
|
}
|
|
408
867
|
this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
|
|
409
868
|
// Extract parameters from the matched route
|
|
410
|
-
|
|
411
|
-
this.
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
869
|
+
params = extractParams(matchedRoute, requestPath);
|
|
870
|
+
if (this.requestGeneration === requestGeneration) {
|
|
871
|
+
this.emit("request:match", {
|
|
872
|
+
method,
|
|
873
|
+
// Every lifecycle event carries the ORIGINAL request path; the
|
|
874
|
+
// namespace-stripped route form is exposed as routePath.
|
|
875
|
+
path,
|
|
876
|
+
routePath: matchedRoute.path,
|
|
877
|
+
params,
|
|
878
|
+
});
|
|
879
|
+
}
|
|
880
|
+
throwIfAborted(signal);
|
|
417
881
|
// Build plugin context before route code so request guards can reject
|
|
418
882
|
// invalid or unauthorized requests without triggering side effects.
|
|
419
883
|
let pluginContext = {
|
|
@@ -423,11 +887,13 @@ export class CallableMockInstance {
|
|
|
423
887
|
params,
|
|
424
888
|
query: reqQuery,
|
|
425
889
|
headers: reqHeaders,
|
|
426
|
-
body:
|
|
890
|
+
body: requestBody,
|
|
427
891
|
state: new Map(),
|
|
428
|
-
routeState:
|
|
892
|
+
routeState: requestState,
|
|
893
|
+
signal,
|
|
429
894
|
};
|
|
430
|
-
const preflightResult = await runPluginBeforeRequest(
|
|
895
|
+
const preflightResult = await runPluginBeforeRequest(requestPlugins, pluginContext, this.logger, signal);
|
|
896
|
+
throwIfAborted(signal);
|
|
431
897
|
pluginContext = preflightResult.context;
|
|
432
898
|
if (preflightResult.requestShortCircuited === true) {
|
|
433
899
|
pluginContext = { ...pluginContext, requestShortCircuited: true };
|
|
@@ -442,18 +908,23 @@ export class CallableMockInstance {
|
|
|
442
908
|
query: pluginContext.query,
|
|
443
909
|
headers: pluginContext.headers,
|
|
444
910
|
body: pluginContext.body,
|
|
445
|
-
state: pluginContext.routeState ??
|
|
911
|
+
state: pluginContext.routeState ?? requestState,
|
|
912
|
+
pluginState: pluginContext.state,
|
|
913
|
+
signal,
|
|
446
914
|
};
|
|
447
915
|
try {
|
|
448
916
|
if (isGeneratorFunction(matchedRoute.generator)) {
|
|
449
|
-
result = await matchedRoute.generator(context);
|
|
917
|
+
result = await awaitWithAbort(matchedRoute.generator(context), signal);
|
|
450
918
|
}
|
|
451
919
|
else {
|
|
452
920
|
result = matchedRoute.generator;
|
|
453
921
|
}
|
|
922
|
+
throwIfAborted(signal);
|
|
454
923
|
}
|
|
455
924
|
catch (error) {
|
|
456
|
-
|
|
925
|
+
throwIfAborted(signal);
|
|
926
|
+
const recovery = await recoverGeneratorError(requestPlugins, pluginContext, error, this.logger, signal);
|
|
927
|
+
throwIfAborted(signal);
|
|
457
928
|
pluginContext = recovery.context;
|
|
458
929
|
result = recovery.response;
|
|
459
930
|
skipPostProcessing = recovery.recoveredFromError === true;
|
|
@@ -465,7 +936,8 @@ export class CallableMockInstance {
|
|
|
465
936
|
this.logger.log("pipeline", "Skipping response processors after error recovery");
|
|
466
937
|
}
|
|
467
938
|
else {
|
|
468
|
-
const pipelineResult = await runPluginPipeline(
|
|
939
|
+
const pipelineResult = await runPluginPipeline(requestPlugins, pluginContext, result, this.logger, signal);
|
|
940
|
+
throwIfAborted(signal);
|
|
469
941
|
pluginContext = pipelineResult.context;
|
|
470
942
|
result = pipelineResult.response;
|
|
471
943
|
}
|
|
@@ -475,73 +947,154 @@ export class CallableMockInstance {
|
|
|
475
947
|
throw error;
|
|
476
948
|
}
|
|
477
949
|
// Parse and prepare response
|
|
478
|
-
const response = parseResponse(result, matchedRoute.config);
|
|
479
|
-
|
|
480
|
-
await this.applyDelay(matchedRoute.config.delay);
|
|
481
|
-
// Record request in history (FIFO-bounded when maxHistorySize is set)
|
|
482
|
-
this.requestHistory.push({
|
|
950
|
+
const response = normalizeResponse(parseResponse(result, matchedRoute.config), method);
|
|
951
|
+
await this.#finalizeMatchedRequest({
|
|
483
952
|
method,
|
|
484
|
-
path
|
|
953
|
+
path,
|
|
954
|
+
requestPath,
|
|
485
955
|
params,
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
method,
|
|
500
|
-
path: requestPath,
|
|
501
|
-
status: response.status,
|
|
502
|
-
duration: performance.now() - handleStart,
|
|
956
|
+
reqQuery,
|
|
957
|
+
reqHeaders,
|
|
958
|
+
requestBody,
|
|
959
|
+
response,
|
|
960
|
+
routeDelay: matchedRoute.config.delay,
|
|
961
|
+
globalDelay,
|
|
962
|
+
record: true,
|
|
963
|
+
signal,
|
|
964
|
+
requestGeneration,
|
|
965
|
+
historyGeneration,
|
|
966
|
+
maxHistorySize,
|
|
967
|
+
requestId,
|
|
968
|
+
handleStart,
|
|
503
969
|
});
|
|
504
|
-
// Log successful response
|
|
505
|
-
this.logger.log("response", `[${requestId}] Sending response ${response.status}`, {
|
|
506
|
-
status: response.status,
|
|
507
|
-
headers: response.headers,
|
|
508
|
-
bodyType: typeof response.body,
|
|
509
|
-
});
|
|
510
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
511
970
|
return response;
|
|
512
971
|
}
|
|
513
972
|
catch (error) {
|
|
973
|
+
throwIfAborted(signal);
|
|
514
974
|
this.logger.log("error", `[${requestId}] Error processing request: ${errorMessage(error)}`, error);
|
|
515
975
|
// Return error response
|
|
516
|
-
const
|
|
976
|
+
const responseError = error instanceof Error ? error : new Error(errorMessage(error));
|
|
977
|
+
const errorResponse = markResponseException(normalizeResponse({
|
|
517
978
|
status: 500,
|
|
518
979
|
body: {
|
|
519
|
-
error:
|
|
980
|
+
error: responseError.message,
|
|
520
981
|
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
|
|
521
982
|
},
|
|
522
983
|
headers: { "content-type": "application/json" },
|
|
523
|
-
};
|
|
524
|
-
//
|
|
525
|
-
|
|
526
|
-
this
|
|
984
|
+
}, method), responseError);
|
|
985
|
+
// A request that matched a route did happen: it is finalized exactly like
|
|
986
|
+
// a successful one — its own delay override, and a history record.
|
|
987
|
+
await this.#finalizeMatchedRequest({
|
|
527
988
|
method,
|
|
528
989
|
path,
|
|
529
|
-
|
|
530
|
-
|
|
990
|
+
requestPath,
|
|
991
|
+
params,
|
|
992
|
+
reqQuery,
|
|
993
|
+
reqHeaders,
|
|
994
|
+
requestBody,
|
|
995
|
+
response: errorResponse,
|
|
996
|
+
routeDelay: matchedRoute?.config.delay,
|
|
997
|
+
globalDelay,
|
|
998
|
+
record: matchedRoute !== undefined,
|
|
999
|
+
signal,
|
|
1000
|
+
requestGeneration,
|
|
1001
|
+
historyGeneration,
|
|
1002
|
+
maxHistorySize,
|
|
1003
|
+
requestId,
|
|
1004
|
+
handleStart,
|
|
531
1005
|
});
|
|
532
|
-
this.logger.log("error", `[${requestId}] Returning error response 500`);
|
|
533
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
534
1006
|
return errorResponse;
|
|
535
1007
|
}
|
|
536
1008
|
}
|
|
1009
|
+
/**
|
|
1010
|
+
* Finish a request that matched a route.
|
|
1011
|
+
*
|
|
1012
|
+
* Order matters: delay first (an abort during it must escape before anything
|
|
1013
|
+
* is committed), then the history record, then `request:end`, then the logs.
|
|
1014
|
+
*/
|
|
1015
|
+
async #finalizeMatchedRequest(input) {
|
|
1016
|
+
const { response, maxHistorySize } = input;
|
|
1017
|
+
// Apply delay (route-level overrides global)
|
|
1018
|
+
await this.applyDelay(input.routeDelay, input.globalDelay, input.signal);
|
|
1019
|
+
throwIfAborted(input.signal);
|
|
1020
|
+
// Record request in history (FIFO-bounded when maxHistorySize is set)
|
|
1021
|
+
if (input.record &&
|
|
1022
|
+
this.requestGeneration === input.requestGeneration &&
|
|
1023
|
+
this.historyGeneration === input.historyGeneration &&
|
|
1024
|
+
maxHistorySize !== 0) {
|
|
1025
|
+
this.requestHistory.push({
|
|
1026
|
+
method: input.method,
|
|
1027
|
+
path: input.requestPath,
|
|
1028
|
+
params: { ...input.params },
|
|
1029
|
+
query: { ...input.reqQuery },
|
|
1030
|
+
headers: { ...input.reqHeaders },
|
|
1031
|
+
body: snapshotHistoryValue(input.requestBody),
|
|
1032
|
+
timestamp: Date.now(),
|
|
1033
|
+
response: {
|
|
1034
|
+
status: response.status,
|
|
1035
|
+
body: snapshotHistoryValue(response.body),
|
|
1036
|
+
},
|
|
1037
|
+
});
|
|
1038
|
+
// The constructor already rejected a limit that is not a non-negative
|
|
1039
|
+
// integer, so a plain comparison is enough here.
|
|
1040
|
+
if (maxHistorySize !== undefined &&
|
|
1041
|
+
this.requestHistory.length > maxHistorySize) {
|
|
1042
|
+
this.requestHistory.splice(0, this.requestHistory.length - maxHistorySize);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
if (this.requestGeneration === input.requestGeneration) {
|
|
1046
|
+
this.emit("request:end", {
|
|
1047
|
+
method: input.method,
|
|
1048
|
+
path: input.path,
|
|
1049
|
+
status: response.status,
|
|
1050
|
+
duration: performance.now() - input.handleStart,
|
|
1051
|
+
});
|
|
1052
|
+
}
|
|
1053
|
+
this.logger.log("response", `[${input.requestId}] Sending response ${response.status}`, {
|
|
1054
|
+
status: response.status,
|
|
1055
|
+
headers: redactSensitiveHeaders(response.headers),
|
|
1056
|
+
bodyType: typeof response.body,
|
|
1057
|
+
});
|
|
1058
|
+
this.logger.timeEnd(`request-${input.requestId}`);
|
|
1059
|
+
}
|
|
1060
|
+
/**
|
|
1061
|
+
* Finish a request that matched no route — an unknown path or one outside the
|
|
1062
|
+
* configured namespace. Misses stay delay-free and out of history: nothing
|
|
1063
|
+
* ran, so there is nothing to record.
|
|
1064
|
+
*/
|
|
1065
|
+
#finalizeMiss(input) {
|
|
1066
|
+
if (this.requestGeneration === input.requestGeneration) {
|
|
1067
|
+
this.emit("request:notfound", {
|
|
1068
|
+
method: input.method,
|
|
1069
|
+
path: input.path,
|
|
1070
|
+
});
|
|
1071
|
+
}
|
|
1072
|
+
const error = new RouteNotFoundError(input.method, input.path);
|
|
1073
|
+
const response = markRouteNotFound(normalizeResponse({
|
|
1074
|
+
status: 404,
|
|
1075
|
+
body: { error: error.message, code: error.code },
|
|
1076
|
+
headers: { "content-type": "application/json" },
|
|
1077
|
+
}, input.method));
|
|
1078
|
+
if (this.requestGeneration === input.requestGeneration) {
|
|
1079
|
+
this.emit("request:end", {
|
|
1080
|
+
method: input.method,
|
|
1081
|
+
path: input.path,
|
|
1082
|
+
status: 404,
|
|
1083
|
+
duration: performance.now() - input.handleStart,
|
|
1084
|
+
});
|
|
1085
|
+
}
|
|
1086
|
+
this.logger.timeEnd(`request-${input.requestId}`);
|
|
1087
|
+
return response;
|
|
1088
|
+
}
|
|
537
1089
|
/**
|
|
538
1090
|
* Apply configured response delay
|
|
539
1091
|
* Supports both fixed delays and random delays within a range
|
|
540
1092
|
* @private
|
|
541
1093
|
*/
|
|
542
|
-
async applyDelay(routeDelay) {
|
|
543
|
-
const effectiveDelay = routeDelay ??
|
|
1094
|
+
async applyDelay(routeDelay, globalDelay, signal) {
|
|
1095
|
+
const effectiveDelay = routeDelay ?? globalDelay;
|
|
544
1096
|
if (!effectiveDelay) {
|
|
1097
|
+
throwIfAborted(signal);
|
|
545
1098
|
return;
|
|
546
1099
|
}
|
|
547
1100
|
const configuredMs = Array.isArray(effectiveDelay)
|
|
@@ -549,6 +1102,23 @@ export class CallableMockInstance {
|
|
|
549
1102
|
effectiveDelay[0]
|
|
550
1103
|
: effectiveDelay;
|
|
551
1104
|
const ms = Math.max(0, configuredMs);
|
|
552
|
-
|
|
1105
|
+
throwIfAborted(signal);
|
|
1106
|
+
await new Promise((resolve, reject) => {
|
|
1107
|
+
const finish = () => {
|
|
1108
|
+
signal?.removeEventListener("abort", abort);
|
|
1109
|
+
resolve();
|
|
1110
|
+
};
|
|
1111
|
+
const abort = () => {
|
|
1112
|
+
clearTimeout(timer);
|
|
1113
|
+
try {
|
|
1114
|
+
throwIfAborted(signal);
|
|
1115
|
+
}
|
|
1116
|
+
catch (error) {
|
|
1117
|
+
reject(error);
|
|
1118
|
+
}
|
|
1119
|
+
};
|
|
1120
|
+
const timer = setTimeout(finish, ms);
|
|
1121
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
1122
|
+
});
|
|
553
1123
|
}
|
|
554
1124
|
}
|