@schmock/core 2.2.3 → 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/binary.d.ts +8 -0
- package/dist/binary.d.ts.map +1 -0
- package/dist/binary.js +9 -0
- package/dist/builder.d.ts +31 -10
- package/dist/builder.d.ts.map +1 -1
- package/dist/builder.js +804 -203
- 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 +52 -5
- package/dist/http-helpers.d.ts.map +1 -1
- package/dist/http-helpers.js +153 -37
- package/dist/index.d.ts +381 -60
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +9 -3
- package/dist/interceptor.d.ts +11 -1
- package/dist/interceptor.d.ts.map +1 -1
- package/dist/interceptor.js +354 -156
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +12 -3
- package/dist/plugin-pipeline.d.ts +11 -4
- package/dist/plugin-pipeline.d.ts.map +1 -1
- package/dist/plugin-pipeline.js +117 -39
- 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 +85 -18
- 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 +18 -8
- package/src/audit-core-builder.test.ts +0 -218
- package/src/audit-response-guard.test.ts +0 -38
- package/src/builder.test.ts +0 -289
- package/src/builder.ts +0 -701
- 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.ts +0 -113
- package/src/index.ts +0 -151
- package/src/interceptor.test.ts +0 -291
- package/src/interceptor.ts +0 -320
- package/src/namespace.test.ts +0 -274
- package/src/parser.property.test.ts +0 -594
- package/src/parser.test.ts +0 -148
- package/src/parser.ts +0 -64
- package/src/plugin-pipeline.ts +0 -103
- package/src/plugin-system.test.ts +0 -602
- package/src/response-parser.ts +0 -75
- 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 -165
- package/src/smart-defaults.test.ts +0 -361
- package/src/steps/async-support.steps.ts +0 -400
- package/src/steps/audit-core-builder.steps.ts +0 -188
- package/src/steps/audit-onerror-tuple.steps.ts +0 -99
- 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 -345
- package/src/steps/fluent-api.steps.ts +0 -255
- package/src/steps/http-methods.steps.ts +0 -331
- package/src/steps/interceptor.steps.ts +0 -260
- package/src/steps/lifecycle-events.steps.ts +0 -142
- package/src/steps/performance-reliability.steps.ts +0 -423
- package/src/steps/plugin-integration.steps.ts +0 -280
- package/src/steps/request-history.steps.ts +0 -276
- 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 -233
- package/src/steps/state-concurrency.steps.ts +0 -739
- package/src/steps/stateful-workflows.steps.ts +0 -353
- package/src/types.ts +0 -36
package/dist/builder.js
CHANGED
|
@@ -1,11 +1,130 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { awaitWithAbort, throwIfAborted } from "./abort.js";
|
|
2
|
+
import { isBinaryBody } from "./binary.js";
|
|
3
|
+
import { canonicalizePath, markResponseException, markRouteNotFound, normalizePath, toHttpMethod, } from "./constants.js";
|
|
2
4
|
import { errorMessage, RouteDefinitionError, RouteNotFoundError, SchmockError, } from "./errors.js";
|
|
3
|
-
import { collectBody, parseNodeHeaders, parseNodeQuery, writeSchmockResponse, } from "./http-helpers.js";
|
|
5
|
+
import { collectBody, HttpIngressError, parseNodeHeaders, parseNodeQuery, writeRejectedSchmockResponse, writeSchmockResponse, } from "./http-helpers.js";
|
|
4
6
|
import { createFetchInterceptor } from "./interceptor.js";
|
|
5
7
|
import { parseRouteKey } from "./parser.js";
|
|
6
|
-
import { runPluginPipeline } from "./plugin-pipeline.js";
|
|
8
|
+
import { recoverGeneratorError, runPluginBeforeRequest, runPluginPipeline, } from "./plugin-pipeline.js";
|
|
9
|
+
import { normalizeResponse } from "./response-normalizer.js";
|
|
7
10
|
import { parseResponse } from "./response-parser.js";
|
|
8
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
|
+
}
|
|
9
128
|
/**
|
|
10
129
|
* Debug logger that respects debug mode configuration
|
|
11
130
|
*/
|
|
@@ -43,7 +162,6 @@ class DebugLogger {
|
|
|
43
162
|
* @internal
|
|
44
163
|
*/
|
|
45
164
|
export class CallableMockInstance {
|
|
46
|
-
globalConfig;
|
|
47
165
|
routes = [];
|
|
48
166
|
staticRoutes = new Map();
|
|
49
167
|
plugins = [];
|
|
@@ -51,12 +169,21 @@ export class CallableMockInstance {
|
|
|
51
169
|
requestHistory = [];
|
|
52
170
|
callableRef;
|
|
53
171
|
server;
|
|
54
|
-
|
|
55
|
-
|
|
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;
|
|
56
179
|
// biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
|
|
57
180
|
listeners = new Map();
|
|
58
181
|
constructor(globalConfig = {}) {
|
|
59
|
-
|
|
182
|
+
assertValidHistoryLimit(globalConfig.maxHistorySize);
|
|
183
|
+
this.globalConfig = {
|
|
184
|
+
...globalConfig,
|
|
185
|
+
state: globalConfig.state ?? {},
|
|
186
|
+
};
|
|
60
187
|
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
61
188
|
if (globalConfig.debug) {
|
|
62
189
|
this.logger.log("config", "Debug mode enabled");
|
|
@@ -83,8 +210,8 @@ export class CallableMockInstance {
|
|
|
83
210
|
// Default to plain text for primitives
|
|
84
211
|
routeConfig.contentType = "text/plain";
|
|
85
212
|
}
|
|
86
|
-
else if (
|
|
87
|
-
// Default to octet-stream for
|
|
213
|
+
else if (isBinaryBody(generator)) {
|
|
214
|
+
// Default to octet-stream for browser and Node binary values
|
|
88
215
|
routeConfig.contentType = "application/octet-stream";
|
|
89
216
|
}
|
|
90
217
|
else {
|
|
@@ -139,6 +266,82 @@ export class CallableMockInstance {
|
|
|
139
266
|
this.callableRef = ref;
|
|
140
267
|
}
|
|
141
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
|
+
}
|
|
142
345
|
this.plugins.push(plugin);
|
|
143
346
|
this.logger.log("plugin", `Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`, {
|
|
144
347
|
name: plugin.name,
|
|
@@ -146,52 +349,79 @@ export class CallableMockInstance {
|
|
|
146
349
|
hasProcess: typeof plugin.process === "function",
|
|
147
350
|
hasOnError: typeof plugin.onError === "function",
|
|
148
351
|
});
|
|
149
|
-
if (plugin.install && this.callableRef) {
|
|
150
|
-
plugin.install(this.callableRef);
|
|
151
|
-
}
|
|
152
352
|
return this;
|
|
153
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
|
+
}
|
|
154
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
|
+
}
|
|
155
389
|
/**
|
|
156
|
-
*
|
|
157
|
-
*
|
|
158
|
-
*
|
|
159
|
-
*
|
|
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.
|
|
160
396
|
*/
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
}
|
|
165
|
-
catch {
|
|
166
|
-
return {
|
|
167
|
-
...r,
|
|
168
|
-
response: { ...r.response },
|
|
169
|
-
};
|
|
170
|
-
}
|
|
397
|
+
#historyMatcher(method, path) {
|
|
398
|
+
const wanted = path === undefined ? undefined : normalizePath(canonicalizePath(path));
|
|
399
|
+
return (r) => (!method || r.method === method) && (!wanted || r.path === wanted);
|
|
171
400
|
}
|
|
172
401
|
history(method, path) {
|
|
173
402
|
if (method || path) {
|
|
174
403
|
return this.requestHistory
|
|
175
|
-
.filter((
|
|
404
|
+
.filter(this.#historyMatcher(method, path))
|
|
176
405
|
.map((r) => this.cloneRecord(r));
|
|
177
406
|
}
|
|
178
407
|
return this.requestHistory.map((r) => this.cloneRecord(r));
|
|
179
408
|
}
|
|
180
409
|
called(method, path) {
|
|
181
410
|
if (method || path) {
|
|
182
|
-
return this.requestHistory.some((
|
|
411
|
+
return this.requestHistory.some(this.#historyMatcher(method, path));
|
|
183
412
|
}
|
|
184
413
|
return this.requestHistory.length > 0;
|
|
185
414
|
}
|
|
186
415
|
callCount(method, path) {
|
|
187
416
|
if (method || path) {
|
|
188
|
-
return this.requestHistory.filter((
|
|
417
|
+
return this.requestHistory.filter(this.#historyMatcher(method, path))
|
|
418
|
+
.length;
|
|
189
419
|
}
|
|
190
420
|
return this.requestHistory.length;
|
|
191
421
|
}
|
|
192
422
|
lastRequest(method, path) {
|
|
193
423
|
if (method || path) {
|
|
194
|
-
const filtered = this.requestHistory.filter((
|
|
424
|
+
const filtered = this.requestHistory.filter(this.#historyMatcher(method, path));
|
|
195
425
|
const last = filtered[filtered.length - 1];
|
|
196
426
|
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
197
427
|
return last ? this.cloneRecord(last) : undefined;
|
|
@@ -227,202 +457,429 @@ export class CallableMockInstance {
|
|
|
227
457
|
}
|
|
228
458
|
emit(event, data) {
|
|
229
459
|
const set = this.listeners.get(event);
|
|
230
|
-
if (set)
|
|
231
|
-
|
|
232
|
-
|
|
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)}`);
|
|
233
481
|
}
|
|
234
482
|
}
|
|
235
483
|
}
|
|
236
484
|
// ===== Reset / Lifecycle =====
|
|
237
485
|
reset() {
|
|
238
|
-
this.
|
|
239
|
-
this.
|
|
486
|
+
const retiredGeneration = this.requestGeneration;
|
|
487
|
+
this.requestGeneration = { activeAdmissions: 0 };
|
|
488
|
+
this.historyGeneration = Symbol("schmock.history.generation");
|
|
240
489
|
this.close();
|
|
490
|
+
const installedPlugins = this.plugins;
|
|
491
|
+
this.plugins = [];
|
|
492
|
+
this.#retireRequestGeneration(retiredGeneration, installedPlugins);
|
|
241
493
|
this.routes = [];
|
|
242
494
|
this.staticRoutes.clear();
|
|
243
|
-
this.plugins = [];
|
|
244
495
|
this.requestHistory = [];
|
|
245
496
|
this.listeners.clear();
|
|
246
|
-
|
|
247
|
-
// reference — this avoids mutating the external state object passed by the user
|
|
248
|
-
if (this.globalConfig.state) {
|
|
249
|
-
this.globalConfig.state = {};
|
|
250
|
-
}
|
|
497
|
+
this.globalConfig.state = {};
|
|
251
498
|
this.logger.log("lifecycle", "Mock fully reset");
|
|
252
499
|
}
|
|
253
500
|
resetHistory() {
|
|
501
|
+
this.historyGeneration = Symbol("schmock.history.generation");
|
|
254
502
|
this.requestHistory = [];
|
|
255
503
|
this.logger.log("lifecycle", "Request history cleared");
|
|
256
504
|
}
|
|
257
505
|
resetState() {
|
|
258
|
-
|
|
259
|
-
// reference — this avoids mutating the external state object passed by the user
|
|
260
|
-
if (this.globalConfig.state) {
|
|
261
|
-
this.globalConfig.state = {};
|
|
262
|
-
}
|
|
506
|
+
this.globalConfig.state = {};
|
|
263
507
|
this.logger.log("lifecycle", "State cleared");
|
|
264
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
|
+
}
|
|
265
552
|
// ===== Standalone Server =====
|
|
266
553
|
listen(port = 0, hostname = "127.0.0.1") {
|
|
267
|
-
if (this.server) {
|
|
554
|
+
if (this.server || this.pendingServerStart) {
|
|
268
555
|
throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
|
|
269
556
|
}
|
|
270
|
-
|
|
271
|
-
|
|
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;
|
|
272
588
|
}
|
|
273
|
-
#startHttpServer(
|
|
589
|
+
#startHttpServer(operation, createServer) {
|
|
274
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";
|
|
275
597
|
const handleRequest = async () => {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
headers
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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
|
+
}
|
|
288
619
|
};
|
|
289
620
|
handleRequest().catch((error) => {
|
|
290
|
-
|
|
291
|
-
|
|
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();
|
|
292
662
|
}
|
|
293
|
-
res.end(JSON.stringify({
|
|
294
|
-
error: error instanceof Error ? error.message : "Internal Server Error",
|
|
295
|
-
code: "SERVER_ERROR",
|
|
296
|
-
}));
|
|
297
663
|
});
|
|
298
664
|
});
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
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, () => {
|
|
672
|
+
httpServer.off("error", handleStartupError);
|
|
673
|
+
if (!this.#ownsServerStart(operation)) {
|
|
674
|
+
this.#beginServerClose(httpServer);
|
|
675
|
+
return;
|
|
676
|
+
}
|
|
303
677
|
const addr = httpServer.address();
|
|
304
|
-
const actualPort = addr !== null && typeof addr === "object"
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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);
|
|
308
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
|
+
}
|
|
309
734
|
});
|
|
310
735
|
}
|
|
311
736
|
close() {
|
|
312
|
-
|
|
737
|
+
this.#cancelServerStart();
|
|
738
|
+
const server = this.server;
|
|
739
|
+
if (!server)
|
|
313
740
|
return;
|
|
314
|
-
}
|
|
315
|
-
this.server.closeAllConnections();
|
|
316
|
-
this.server.close();
|
|
317
741
|
this.server = undefined;
|
|
318
|
-
this
|
|
742
|
+
this.#beginServerClose(server);
|
|
319
743
|
this.logger.log("server", "Server stopped");
|
|
320
744
|
}
|
|
321
745
|
// ===== Fetch Interceptor =====
|
|
322
746
|
intercept(options) {
|
|
323
|
-
|
|
324
|
-
|
|
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);
|
|
325
778
|
}
|
|
326
|
-
this.interceptHandle = createFetchInterceptor((method, path, opts) => this.handle(method, path, opts), options);
|
|
327
|
-
return this.interceptHandle;
|
|
328
779
|
}
|
|
329
|
-
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);
|
|
330
797
|
const handleStart = performance.now();
|
|
331
798
|
const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
|
|
332
|
-
const reqQuery = options?.query
|
|
333
|
-
const reqHeaders = options?.headers
|
|
799
|
+
const reqQuery = { ...(options?.query ?? {}) };
|
|
800
|
+
const reqHeaders = { ...(options?.headers ?? {}) };
|
|
801
|
+
const requestBody = options?.body;
|
|
334
802
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
335
|
-
headers: reqHeaders,
|
|
803
|
+
headers: redactSensitiveHeaders(reqHeaders),
|
|
336
804
|
query: reqQuery,
|
|
337
|
-
|
|
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",
|
|
338
809
|
});
|
|
339
810
|
this.logger.time(`request-${requestId}`);
|
|
340
|
-
this.
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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 = {};
|
|
345
823
|
try {
|
|
346
824
|
// Apply namespace if configured
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
const namespace = this.globalConfig.namespace.startsWith("/")
|
|
350
|
-
? this.globalConfig.namespace
|
|
351
|
-
: `/${this.globalConfig.namespace}`;
|
|
825
|
+
if (namespace && namespace !== "/") {
|
|
826
|
+
const normalizedNamespace = canonicalizePath(namespace.startsWith("/") ? namespace : `/${namespace}`);
|
|
352
827
|
const pathToCheck = path.startsWith("/") ? path : `/${path}`;
|
|
353
828
|
// Check if path starts with namespace
|
|
354
829
|
// handle both "/api/users" (starts with /api) and "/api" (exact match)
|
|
355
830
|
// but NOT "/apiv2" (prefix match but wrong segment)
|
|
356
|
-
const isMatch = pathToCheck ===
|
|
357
|
-
pathToCheck.startsWith(
|
|
831
|
+
const isMatch = pathToCheck === normalizedNamespace ||
|
|
832
|
+
pathToCheck.startsWith(normalizedNamespace.endsWith("/")
|
|
833
|
+
? normalizedNamespace
|
|
834
|
+
: `${normalizedNamespace}/`);
|
|
358
835
|
if (!isMatch) {
|
|
359
|
-
this.logger.log("route", `[${requestId}] Path doesn't match namespace ${
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
body: { error: error.message, code: error.code },
|
|
364
|
-
headers: {},
|
|
365
|
-
};
|
|
366
|
-
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({
|
|
367
840
|
method,
|
|
368
841
|
path,
|
|
369
|
-
|
|
370
|
-
|
|
842
|
+
requestId,
|
|
843
|
+
handleStart,
|
|
844
|
+
requestGeneration,
|
|
371
845
|
});
|
|
372
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
373
|
-
return response;
|
|
374
846
|
}
|
|
375
847
|
// Remove namespace prefix, ensuring we always start with /
|
|
376
|
-
const stripped = pathToCheck.slice(
|
|
848
|
+
const stripped = pathToCheck.slice(normalizedNamespace.length);
|
|
377
849
|
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
378
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);
|
|
379
855
|
// Find matching route
|
|
380
|
-
|
|
856
|
+
matchedRoute = findRoute(method, requestPath, requestStaticRoutes, requestRoutes);
|
|
381
857
|
if (!matchedRoute) {
|
|
382
858
|
this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
|
|
383
|
-
this
|
|
384
|
-
const error = new RouteNotFoundError(method, path);
|
|
385
|
-
const response = {
|
|
386
|
-
status: 404,
|
|
387
|
-
body: { error: error.message, code: error.code },
|
|
388
|
-
headers: {},
|
|
389
|
-
};
|
|
390
|
-
this.emit("request:end", {
|
|
859
|
+
return this.#finalizeMiss({
|
|
391
860
|
method,
|
|
392
|
-
path
|
|
393
|
-
|
|
394
|
-
|
|
861
|
+
path,
|
|
862
|
+
requestId,
|
|
863
|
+
handleStart,
|
|
864
|
+
requestGeneration,
|
|
395
865
|
});
|
|
396
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
397
|
-
return response;
|
|
398
866
|
}
|
|
399
867
|
this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
|
|
400
868
|
// Extract parameters from the matched route
|
|
401
|
-
|
|
402
|
-
this.
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
path: requestPath,
|
|
412
|
-
params,
|
|
413
|
-
query: reqQuery,
|
|
414
|
-
headers: reqHeaders,
|
|
415
|
-
body: options?.body,
|
|
416
|
-
state: this.globalConfig.state || {},
|
|
417
|
-
};
|
|
418
|
-
let result;
|
|
419
|
-
if (isGeneratorFunction(matchedRoute.generator)) {
|
|
420
|
-
result = await matchedRoute.generator(context);
|
|
421
|
-
}
|
|
422
|
-
else {
|
|
423
|
-
result = matchedRoute.generator;
|
|
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
|
+
});
|
|
424
879
|
}
|
|
425
|
-
|
|
880
|
+
throwIfAborted(signal);
|
|
881
|
+
// Build plugin context before route code so request guards can reject
|
|
882
|
+
// invalid or unauthorized requests without triggering side effects.
|
|
426
883
|
let pluginContext = {
|
|
427
884
|
path: requestPath,
|
|
428
885
|
route: matchedRoute.config,
|
|
@@ -430,94 +887,238 @@ export class CallableMockInstance {
|
|
|
430
887
|
params,
|
|
431
888
|
query: reqQuery,
|
|
432
889
|
headers: reqHeaders,
|
|
433
|
-
body:
|
|
890
|
+
body: requestBody,
|
|
434
891
|
state: new Map(),
|
|
435
|
-
routeState:
|
|
892
|
+
routeState: requestState,
|
|
893
|
+
signal,
|
|
436
894
|
};
|
|
895
|
+
const preflightResult = await runPluginBeforeRequest(requestPlugins, pluginContext, this.logger, signal);
|
|
896
|
+
throwIfAborted(signal);
|
|
897
|
+
pluginContext = preflightResult.context;
|
|
898
|
+
if (preflightResult.requestShortCircuited === true) {
|
|
899
|
+
pluginContext = { ...pluginContext, requestShortCircuited: true };
|
|
900
|
+
}
|
|
901
|
+
let result = preflightResult.response;
|
|
902
|
+
let skipPostProcessing = preflightResult.recoveredFromError === true;
|
|
903
|
+
if (result === undefined) {
|
|
904
|
+
const context = {
|
|
905
|
+
method: pluginContext.method,
|
|
906
|
+
path: pluginContext.path,
|
|
907
|
+
params: pluginContext.params,
|
|
908
|
+
query: pluginContext.query,
|
|
909
|
+
headers: pluginContext.headers,
|
|
910
|
+
body: pluginContext.body,
|
|
911
|
+
state: pluginContext.routeState ?? requestState,
|
|
912
|
+
pluginState: pluginContext.state,
|
|
913
|
+
signal,
|
|
914
|
+
};
|
|
915
|
+
try {
|
|
916
|
+
if (isGeneratorFunction(matchedRoute.generator)) {
|
|
917
|
+
result = await awaitWithAbort(matchedRoute.generator(context), signal);
|
|
918
|
+
}
|
|
919
|
+
else {
|
|
920
|
+
result = matchedRoute.generator;
|
|
921
|
+
}
|
|
922
|
+
throwIfAborted(signal);
|
|
923
|
+
}
|
|
924
|
+
catch (error) {
|
|
925
|
+
throwIfAborted(signal);
|
|
926
|
+
const recovery = await recoverGeneratorError(requestPlugins, pluginContext, error, this.logger, signal);
|
|
927
|
+
throwIfAborted(signal);
|
|
928
|
+
pluginContext = recovery.context;
|
|
929
|
+
result = recovery.response;
|
|
930
|
+
skipPostProcessing = recovery.recoveredFromError === true;
|
|
931
|
+
}
|
|
932
|
+
}
|
|
437
933
|
// Run plugin pipeline to transform the response
|
|
438
934
|
try {
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
935
|
+
if (skipPostProcessing) {
|
|
936
|
+
this.logger.log("pipeline", "Skipping response processors after error recovery");
|
|
937
|
+
}
|
|
938
|
+
else {
|
|
939
|
+
const pipelineResult = await runPluginPipeline(requestPlugins, pluginContext, result, this.logger, signal);
|
|
940
|
+
throwIfAborted(signal);
|
|
941
|
+
pluginContext = pipelineResult.context;
|
|
942
|
+
result = pipelineResult.response;
|
|
943
|
+
}
|
|
442
944
|
}
|
|
443
945
|
catch (error) {
|
|
444
946
|
this.logger.log("error", `[${requestId}] Plugin pipeline error: ${errorMessage(error)}`);
|
|
445
947
|
throw error;
|
|
446
948
|
}
|
|
447
949
|
// Parse and prepare response
|
|
448
|
-
const response = parseResponse(result, matchedRoute.config);
|
|
449
|
-
|
|
450
|
-
await this.applyDelay(matchedRoute.config.delay);
|
|
451
|
-
// Record request in history (FIFO-bounded when maxHistorySize is set)
|
|
452
|
-
this.requestHistory.push({
|
|
950
|
+
const response = normalizeResponse(parseResponse(result, matchedRoute.config), method);
|
|
951
|
+
await this.#finalizeMatchedRequest({
|
|
453
952
|
method,
|
|
454
|
-
path
|
|
953
|
+
path,
|
|
954
|
+
requestPath,
|
|
455
955
|
params,
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
method,
|
|
470
|
-
path: requestPath,
|
|
471
|
-
status: response.status,
|
|
472
|
-
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,
|
|
473
969
|
});
|
|
474
|
-
// Log successful response
|
|
475
|
-
this.logger.log("response", `[${requestId}] Sending response ${response.status}`, {
|
|
476
|
-
status: response.status,
|
|
477
|
-
headers: response.headers,
|
|
478
|
-
bodyType: typeof response.body,
|
|
479
|
-
});
|
|
480
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
481
970
|
return response;
|
|
482
971
|
}
|
|
483
972
|
catch (error) {
|
|
973
|
+
throwIfAborted(signal);
|
|
484
974
|
this.logger.log("error", `[${requestId}] Error processing request: ${errorMessage(error)}`, error);
|
|
485
975
|
// Return error response
|
|
486
|
-
const
|
|
976
|
+
const responseError = error instanceof Error ? error : new Error(errorMessage(error));
|
|
977
|
+
const errorResponse = markResponseException(normalizeResponse({
|
|
487
978
|
status: 500,
|
|
488
979
|
body: {
|
|
489
|
-
error:
|
|
980
|
+
error: responseError.message,
|
|
490
981
|
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
|
|
491
982
|
},
|
|
492
|
-
headers: {},
|
|
493
|
-
};
|
|
494
|
-
//
|
|
495
|
-
|
|
496
|
-
this
|
|
983
|
+
headers: { "content-type": "application/json" },
|
|
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({
|
|
497
988
|
method,
|
|
498
989
|
path,
|
|
499
|
-
|
|
500
|
-
|
|
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,
|
|
501
1005
|
});
|
|
502
|
-
this.logger.log("error", `[${requestId}] Returning error response 500`);
|
|
503
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
504
1006
|
return errorResponse;
|
|
505
1007
|
}
|
|
506
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
|
+
}
|
|
507
1089
|
/**
|
|
508
1090
|
* Apply configured response delay
|
|
509
1091
|
* Supports both fixed delays and random delays within a range
|
|
510
1092
|
* @private
|
|
511
1093
|
*/
|
|
512
|
-
async applyDelay(routeDelay) {
|
|
513
|
-
const effectiveDelay = routeDelay ??
|
|
1094
|
+
async applyDelay(routeDelay, globalDelay, signal) {
|
|
1095
|
+
const effectiveDelay = routeDelay ?? globalDelay;
|
|
514
1096
|
if (!effectiveDelay) {
|
|
1097
|
+
throwIfAborted(signal);
|
|
515
1098
|
return;
|
|
516
1099
|
}
|
|
517
|
-
const
|
|
1100
|
+
const configuredMs = Array.isArray(effectiveDelay)
|
|
518
1101
|
? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
|
|
519
1102
|
effectiveDelay[0]
|
|
520
1103
|
: effectiveDelay;
|
|
521
|
-
|
|
1104
|
+
const ms = Math.max(0, configuredMs);
|
|
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
|
+
});
|
|
522
1123
|
}
|
|
523
1124
|
}
|