@schmock/core 2.4.1 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +129 -0
- package/dist/abort.d.ts +11 -1
- package/dist/abort.js +13 -2
- package/dist/adapter.d.ts +21 -0
- package/dist/adapter.js +19 -0
- package/dist/admission.d.ts +21 -0
- package/dist/admission.js +39 -0
- package/dist/binary.d.ts +0 -1
- package/dist/builder.d.ts +16 -32
- package/dist/builder.js +425 -904
- package/dist/constants.d.ts +33 -2
- package/dist/constants.js +73 -1
- package/dist/debug-logger.d.ts +10 -0
- package/dist/debug-logger.js +31 -0
- package/dist/delay.d.ts +12 -0
- package/dist/delay.js +37 -0
- package/dist/errors.d.ts +13 -2
- package/dist/errors.js +21 -2
- package/dist/events.d.ts +17 -0
- package/dist/events.js +58 -0
- package/dist/generations.d.ts +48 -0
- package/dist/generations.js +82 -0
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +57 -0
- package/dist/helpers.d.ts +9 -10
- package/dist/helpers.js +4 -1
- package/dist/history.d.ts +56 -0
- package/dist/history.js +151 -0
- package/dist/http-helpers.d.ts +110 -5
- package/dist/http-helpers.js +328 -46
- package/dist/index.d.ts +295 -31
- package/dist/index.js +17 -9
- package/dist/interceptor.d.ts +40 -10
- package/dist/interceptor.js +412 -178
- package/dist/node-server.d.ts +27 -0
- package/dist/node-server.js +166 -0
- package/dist/parser.d.ts +0 -1
- package/dist/parser.js +145 -22
- package/dist/plugin-hooks.d.ts +57 -0
- package/dist/plugin-hooks.js +276 -0
- package/dist/plugin-pipeline.d.ts +0 -1
- package/dist/plugin-pipeline.js +25 -4
- package/dist/response-normalizer.d.ts +36 -1
- package/dist/response-normalizer.js +102 -0
- package/dist/response-parser.d.ts +19 -1
- package/dist/response-parser.js +77 -19
- package/dist/route-matcher.d.ts +0 -1
- package/dist/route-table.d.ts +64 -0
- package/dist/route-table.js +220 -0
- package/dist/snapshot.d.ts +14 -0
- package/dist/snapshot.js +98 -0
- package/dist/types.d.ts +34 -1
- package/package.json +8 -3
- package/dist/abort.d.ts.map +0 -1
- package/dist/binary.d.ts.map +0 -1
- package/dist/builder.d.ts.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/helpers.d.ts.map +0 -1
- package/dist/http-helpers.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/interceptor.d.ts.map +0 -1
- package/dist/parser.d.ts.map +0 -1
- package/dist/plugin-pipeline.d.ts.map +0 -1
- package/dist/response-normalizer.d.ts.map +0 -1
- package/dist/response-parser.d.ts.map +0 -1
- package/dist/route-matcher.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
package/dist/builder.js
CHANGED
|
@@ -1,190 +1,53 @@
|
|
|
1
1
|
import { awaitWithAbort, throwIfAborted } from "./abort.js";
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
2
|
+
import { canonicalizePath, markResponseException, markRouteNotFound, matchPathPrefix, normalizePath, parsePathPrefix, } from "./constants.js";
|
|
3
|
+
import { DebugLogger } from "./debug-logger.js";
|
|
4
|
+
import { applyResponseDelay } from "./delay.js";
|
|
5
|
+
import { errorMessage, RouteNotFoundError, SchmockError } from "./errors.js";
|
|
6
|
+
import { MockEvents } from "./events.js";
|
|
7
|
+
import { RequestGenerations } from "./generations.js";
|
|
8
|
+
import { redactHeaders } from "./headers.js";
|
|
9
|
+
import { RequestHistory } from "./history.js";
|
|
10
|
+
import { createFetchLease, NORMALIZED_ADMISSION_KEY, } from "./interceptor.js";
|
|
11
|
+
import { NodeServerController } from "./node-server.js";
|
|
12
|
+
import { assertValidPlugin, hasExchangeObserver, runExchangeHooks, runInstallHook, runUninstallHooks, } from "./plugin-hooks.js";
|
|
8
13
|
import { recoverGeneratorError, runPluginBeforeRequest, runPluginPipeline, } from "./plugin-pipeline.js";
|
|
9
|
-
import { normalizeResponse } from "./response-normalizer.js";
|
|
14
|
+
import { buildJsonErrorResponse, normalizeResponse, } from "./response-normalizer.js";
|
|
10
15
|
import { parseResponse } from "./response-parser.js";
|
|
11
16
|
import { extractParams, findRoute, isGeneratorFunction, } from "./route-matcher.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
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
|
-
}
|
|
128
|
-
/**
|
|
129
|
-
* Debug logger that respects debug mode configuration
|
|
130
|
-
*/
|
|
131
|
-
class DebugLogger {
|
|
132
|
-
enabled;
|
|
133
|
-
constructor(enabled = false) {
|
|
134
|
-
this.enabled = enabled;
|
|
135
|
-
}
|
|
136
|
-
log(category, message, data) {
|
|
137
|
-
if (!this.enabled)
|
|
138
|
-
return;
|
|
139
|
-
const timestamp = new Date().toISOString();
|
|
140
|
-
const prefix = `[${timestamp}] [SCHMOCK:${category.toUpperCase()}]`;
|
|
141
|
-
if (data) {
|
|
142
|
-
console.log(`${prefix} ${message}`, data);
|
|
143
|
-
}
|
|
144
|
-
else {
|
|
145
|
-
console.log(`${prefix} ${message}`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
time(label) {
|
|
149
|
-
if (!this.enabled)
|
|
150
|
-
return;
|
|
151
|
-
console.time(`[SCHMOCK] ${label}`);
|
|
152
|
-
}
|
|
153
|
-
timeEnd(label) {
|
|
154
|
-
if (!this.enabled)
|
|
155
|
-
return;
|
|
156
|
-
console.timeEnd(`[SCHMOCK] ${label}`);
|
|
157
|
-
}
|
|
158
|
-
}
|
|
17
|
+
import { copyRouteConfig, copyStaticData, RouteTable } from "./route-table.js";
|
|
18
|
+
/** `request:end` status for a request its caller cancelled. */
|
|
19
|
+
const ABORTED_REQUEST_STATUS = 499;
|
|
159
20
|
/**
|
|
160
21
|
* Callable mock instance that implements the new API.
|
|
161
22
|
*
|
|
162
23
|
* @internal
|
|
163
24
|
*/
|
|
164
25
|
export class CallableMockInstance {
|
|
165
|
-
|
|
166
|
-
staticRoutes = new Map();
|
|
26
|
+
routeTable = new RouteTable();
|
|
167
27
|
plugins = [];
|
|
168
28
|
logger;
|
|
169
|
-
requestHistory
|
|
29
|
+
requestHistory;
|
|
30
|
+
nodeServer;
|
|
170
31
|
callableRef;
|
|
171
|
-
server;
|
|
172
|
-
pendingServerStart;
|
|
173
|
-
serverCloseBarrier;
|
|
174
32
|
interceptHandles = new Set();
|
|
175
|
-
|
|
176
|
-
historyGeneration = Symbol("schmock.history.generation");
|
|
33
|
+
generations = new RequestGenerations((plugins) => this.uninstallPlugins(plugins));
|
|
177
34
|
interceptOwner = Symbol("schmock.intercept.owner");
|
|
178
35
|
globalConfig;
|
|
179
|
-
|
|
180
|
-
|
|
36
|
+
events;
|
|
37
|
+
namespaceCache;
|
|
181
38
|
constructor(globalConfig = {}) {
|
|
182
|
-
|
|
39
|
+
// First: an invalid maxHistorySize throws before anything else is built.
|
|
40
|
+
this.requestHistory = new RequestHistory(globalConfig.maxHistorySize);
|
|
183
41
|
this.globalConfig = {
|
|
184
42
|
...globalConfig,
|
|
185
43
|
state: globalConfig.state ?? {},
|
|
186
44
|
};
|
|
187
45
|
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
46
|
+
this.events = new MockEvents(this.logger);
|
|
47
|
+
this.nodeServer = new NodeServerController({
|
|
48
|
+
admitRequest: () => this.createRequestAdmission(),
|
|
49
|
+
logger: this.logger,
|
|
50
|
+
});
|
|
188
51
|
if (globalConfig.debug) {
|
|
189
52
|
this.logger.log("config", "Debug mode enabled");
|
|
190
53
|
}
|
|
@@ -196,153 +59,44 @@ export class CallableMockInstance {
|
|
|
196
59
|
}
|
|
197
60
|
// Method for defining routes (called when instance is invoked)
|
|
198
61
|
defineRoute(route, generator, config) {
|
|
199
|
-
|
|
200
|
-
const routeConfig = { ...config };
|
|
201
|
-
// Auto-detect contentType if not provided
|
|
202
|
-
if (!routeConfig.contentType) {
|
|
203
|
-
if (typeof generator === "function") {
|
|
204
|
-
// Default to JSON for function generators
|
|
205
|
-
routeConfig.contentType = "application/json";
|
|
206
|
-
}
|
|
207
|
-
else if (typeof generator === "string" ||
|
|
208
|
-
typeof generator === "number" ||
|
|
209
|
-
typeof generator === "boolean") {
|
|
210
|
-
// Default to plain text for primitives
|
|
211
|
-
routeConfig.contentType = "text/plain";
|
|
212
|
-
}
|
|
213
|
-
else if (isBinaryBody(generator)) {
|
|
214
|
-
// Default to octet-stream for browser and Node binary values
|
|
215
|
-
routeConfig.contentType = "application/octet-stream";
|
|
216
|
-
}
|
|
217
|
-
else {
|
|
218
|
-
// Default to JSON for objects/arrays
|
|
219
|
-
routeConfig.contentType = "application/json";
|
|
220
|
-
}
|
|
221
|
-
}
|
|
222
|
-
// Validate generator matches contentType if it's static data
|
|
223
|
-
if (typeof generator !== "function" &&
|
|
224
|
-
routeConfig.contentType === "application/json") {
|
|
225
|
-
try {
|
|
226
|
-
JSON.stringify(generator);
|
|
227
|
-
}
|
|
228
|
-
catch (_error) {
|
|
229
|
-
throw new RouteDefinitionError(route, "Generator data is not valid JSON but contentType is application/json");
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
// Parse the route key to create pattern and extract parameters
|
|
233
|
-
const parsed = parseRouteKey(route);
|
|
234
|
-
// FIX 2.2: normalize paths before duplicate check so /users and /users/ are
|
|
235
|
-
// treated as the same route (consistent with the static-route Map key below)
|
|
236
|
-
const normalizedParsedPath = normalizePath(parsed.path);
|
|
237
|
-
const existing = this.routes.find((r) => r.method === parsed.method &&
|
|
238
|
-
normalizePath(r.path) === normalizedParsedPath);
|
|
239
|
-
if (existing) {
|
|
240
|
-
this.logger.log("warning", `Duplicate route: ${route} — first registration wins`);
|
|
241
|
-
return this;
|
|
242
|
-
}
|
|
243
|
-
// Compile the route
|
|
244
|
-
const compiledRoute = {
|
|
245
|
-
pattern: parsed.pattern,
|
|
246
|
-
params: parsed.params,
|
|
247
|
-
method: parsed.method,
|
|
248
|
-
path: parsed.path,
|
|
249
|
-
generator,
|
|
250
|
-
config: routeConfig,
|
|
251
|
-
};
|
|
252
|
-
this.routes.push(compiledRoute);
|
|
253
|
-
// Store static routes (no params) in Map for O(1) lookup
|
|
254
|
-
if (parsed.params.length === 0) {
|
|
255
|
-
const key = `${parsed.method} ${normalizePath(parsed.path)}`;
|
|
256
|
-
this.staticRoutes.set(key, compiledRoute);
|
|
257
|
-
}
|
|
258
|
-
this.logger.log("route", `Route defined: ${route}`, {
|
|
259
|
-
contentType: routeConfig.contentType,
|
|
260
|
-
generatorType: typeof generator,
|
|
261
|
-
hasParams: parsed.params.length > 0,
|
|
262
|
-
});
|
|
62
|
+
this.routeTable.define({ route, generator, config, logger: this.logger });
|
|
263
63
|
return this;
|
|
264
64
|
}
|
|
265
65
|
setCallableRef(ref) {
|
|
266
66
|
this.callableRef = ref;
|
|
267
67
|
}
|
|
268
68
|
pipe(plugin) {
|
|
69
|
+
assertValidPlugin(plugin);
|
|
70
|
+
if (this.plugins.includes(plugin)) {
|
|
71
|
+
// Piping one object twice used to run install() twice and process()
|
|
72
|
+
// twice per request. It is a no-op rather than an error because the
|
|
73
|
+
// pattern is common and was silently accepted: `.pipe(p)` chained after
|
|
74
|
+
// every route definition, or a beforeEach that pipes without a reset.
|
|
75
|
+
// Distinct objects that share a name (two openapi() specs) still stack.
|
|
76
|
+
this.logger.log("warning", `Plugin ${plugin.name} is already piped into this mock — ignored`);
|
|
77
|
+
return this;
|
|
78
|
+
}
|
|
79
|
+
this.generations.uninstallBeforeReinstall(plugin);
|
|
269
80
|
if (plugin.install && this.callableRef) {
|
|
270
|
-
|
|
271
|
-
const
|
|
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
|
-
});
|
|
81
|
+
// Routes the hook registers before it fails are rolled back with it.
|
|
82
|
+
const checkpoint = this.routeTable.checkpoint();
|
|
326
83
|
try {
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
this.
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
}
|
|
84
|
+
runInstallHook({
|
|
85
|
+
plugin,
|
|
86
|
+
reads: this,
|
|
87
|
+
registerRoute: (route, generator, config) => {
|
|
88
|
+
this.defineRoute(route, generator, config);
|
|
89
|
+
},
|
|
90
|
+
logger: this.logger,
|
|
91
|
+
});
|
|
335
92
|
}
|
|
336
93
|
catch (error) {
|
|
337
|
-
this.
|
|
338
|
-
this.staticRoutes = previousStaticRoutes;
|
|
94
|
+
this.routeTable.rollback(checkpoint);
|
|
339
95
|
throw error;
|
|
340
96
|
}
|
|
341
|
-
finally {
|
|
342
|
-
installActive = false;
|
|
343
|
-
}
|
|
344
97
|
}
|
|
345
|
-
|
|
98
|
+
// Replaced, never pushed into: in-flight admissions hold the old array.
|
|
99
|
+
this.plugins = [...this.plugins, plugin];
|
|
346
100
|
this.logger.log("plugin", `Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`, {
|
|
347
101
|
name: plugin.name,
|
|
348
102
|
version: plugin.version,
|
|
@@ -352,154 +106,58 @@ export class CallableMockInstance {
|
|
|
352
106
|
return this;
|
|
353
107
|
}
|
|
354
108
|
uninstallPlugins(plugins) {
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
}
|
|
109
|
+
if (!this.callableRef)
|
|
110
|
+
return;
|
|
111
|
+
runUninstallHooks({ plugins, reads: this, logger: this.logger });
|
|
372
112
|
}
|
|
373
113
|
// ===== 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
|
-
}
|
|
389
|
-
/**
|
|
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.
|
|
396
|
-
*/
|
|
397
|
-
#historyMatcher(method, path) {
|
|
398
|
-
const wanted = path === undefined ? undefined : normalizePath(canonicalizePath(path));
|
|
399
|
-
return (r) => (!method || r.method === method) && (!wanted || r.path === wanted);
|
|
400
|
-
}
|
|
401
114
|
history(method, path) {
|
|
402
|
-
|
|
403
|
-
return this.requestHistory
|
|
404
|
-
.filter(this.#historyMatcher(method, path))
|
|
405
|
-
.map((r) => this.cloneRecord(r));
|
|
406
|
-
}
|
|
407
|
-
return this.requestHistory.map((r) => this.cloneRecord(r));
|
|
115
|
+
return this.requestHistory.history(method, path);
|
|
408
116
|
}
|
|
409
117
|
called(method, path) {
|
|
410
|
-
|
|
411
|
-
return this.requestHistory.some(this.#historyMatcher(method, path));
|
|
412
|
-
}
|
|
413
|
-
return this.requestHistory.length > 0;
|
|
118
|
+
return this.requestHistory.called(method, path);
|
|
414
119
|
}
|
|
415
120
|
callCount(method, path) {
|
|
416
|
-
|
|
417
|
-
return this.requestHistory.filter(this.#historyMatcher(method, path))
|
|
418
|
-
.length;
|
|
419
|
-
}
|
|
420
|
-
return this.requestHistory.length;
|
|
121
|
+
return this.requestHistory.callCount(method, path);
|
|
421
122
|
}
|
|
422
123
|
lastRequest(method, path) {
|
|
423
|
-
|
|
424
|
-
const filtered = this.requestHistory.filter(this.#historyMatcher(method, path));
|
|
425
|
-
const last = filtered[filtered.length - 1];
|
|
426
|
-
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
427
|
-
return last ? this.cloneRecord(last) : undefined;
|
|
428
|
-
}
|
|
429
|
-
const last = this.requestHistory[this.requestHistory.length - 1];
|
|
430
|
-
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
431
|
-
return last ? this.cloneRecord(last) : undefined;
|
|
124
|
+
return this.requestHistory.lastRequest(method, path);
|
|
432
125
|
}
|
|
433
126
|
// ===== Introspection =====
|
|
434
127
|
getRoutes() {
|
|
435
|
-
return this.
|
|
436
|
-
method: r.method,
|
|
437
|
-
path: r.path,
|
|
438
|
-
hasParams: r.params.length > 0,
|
|
439
|
-
}));
|
|
128
|
+
return this.routeTable.list();
|
|
440
129
|
}
|
|
441
130
|
getState() {
|
|
442
131
|
return { ...(this.globalConfig.state || {}) };
|
|
443
132
|
}
|
|
444
133
|
// ===== Lifecycle Events =====
|
|
445
134
|
on(event, listener) {
|
|
446
|
-
|
|
447
|
-
if (!set) {
|
|
448
|
-
set = new Set();
|
|
449
|
-
this.listeners.set(event, set);
|
|
450
|
-
}
|
|
451
|
-
set.add(listener);
|
|
135
|
+
this.events.on(event, listener);
|
|
452
136
|
return this;
|
|
453
137
|
}
|
|
454
138
|
off(event, listener) {
|
|
455
|
-
this.
|
|
139
|
+
this.events.off(event, listener);
|
|
456
140
|
return this;
|
|
457
141
|
}
|
|
458
|
-
emit(event, data) {
|
|
459
|
-
const set = this.listeners.get(event);
|
|
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)}`);
|
|
481
|
-
}
|
|
482
|
-
}
|
|
483
|
-
}
|
|
484
142
|
// ===== Reset / Lifecycle =====
|
|
485
143
|
reset() {
|
|
486
|
-
const retiredGeneration = this.
|
|
487
|
-
this.
|
|
488
|
-
this.historyGeneration = Symbol("schmock.history.generation");
|
|
144
|
+
const retiredGeneration = this.generations.advance();
|
|
145
|
+
this.requestHistory.startGeneration();
|
|
489
146
|
this.close();
|
|
490
147
|
const installedPlugins = this.plugins;
|
|
491
148
|
this.plugins = [];
|
|
492
|
-
this
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
this.
|
|
496
|
-
this.
|
|
149
|
+
this.generations.retire(retiredGeneration, installedPlugins);
|
|
150
|
+
// Replaced, never cleared in place: in-flight admissions still route with
|
|
151
|
+
// the old containers.
|
|
152
|
+
this.routeTable.clear();
|
|
153
|
+
this.requestHistory.clear();
|
|
154
|
+
this.events.clear();
|
|
497
155
|
this.globalConfig.state = {};
|
|
498
156
|
this.logger.log("lifecycle", "Mock fully reset");
|
|
499
157
|
}
|
|
500
158
|
resetHistory() {
|
|
501
|
-
this.
|
|
502
|
-
this.requestHistory
|
|
159
|
+
this.requestHistory.startGeneration();
|
|
160
|
+
this.requestHistory.clear();
|
|
503
161
|
this.logger.log("lifecycle", "Request history cleared");
|
|
504
162
|
}
|
|
505
163
|
resetState() {
|
|
@@ -507,18 +165,17 @@ export class CallableMockInstance {
|
|
|
507
165
|
this.logger.log("lifecycle", "State cleared");
|
|
508
166
|
}
|
|
509
167
|
#captureRequestAdmission() {
|
|
510
|
-
const requestGeneration = this.
|
|
511
|
-
|
|
168
|
+
const requestGeneration = this.generations.admit();
|
|
169
|
+
// O(1) snapshot: the containers are captured by reference. `plugins` is
|
|
170
|
+
// only ever replaced, and the route tables are copy-on-write.
|
|
512
171
|
return {
|
|
513
172
|
requestGeneration,
|
|
514
|
-
historyGeneration: this.
|
|
515
|
-
plugins: this.plugins
|
|
516
|
-
routes: this.
|
|
517
|
-
staticRoutes: new Map(this.staticRoutes),
|
|
173
|
+
historyGeneration: this.requestHistory.generation,
|
|
174
|
+
plugins: this.plugins,
|
|
175
|
+
routes: this.routeTable.share(),
|
|
518
176
|
state: this.globalConfig.state,
|
|
519
177
|
namespace: this.globalConfig.namespace,
|
|
520
178
|
globalDelay: this.globalConfig.delay,
|
|
521
|
-
maxHistorySize: this.globalConfig.maxHistorySize,
|
|
522
179
|
released: false,
|
|
523
180
|
};
|
|
524
181
|
}
|
|
@@ -526,221 +183,39 @@ export class CallableMockInstance {
|
|
|
526
183
|
if (admission.released)
|
|
527
184
|
return;
|
|
528
185
|
admission.released = true;
|
|
529
|
-
|
|
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
|
-
}
|
|
186
|
+
this.generations.release(admission.requestGeneration);
|
|
544
187
|
}
|
|
545
188
|
createRequestAdmission() {
|
|
546
189
|
const admission = this.#captureRequestAdmission();
|
|
547
|
-
|
|
190
|
+
const admitted = {
|
|
548
191
|
handle: (method, path, options) => this.handle(method, path, options, admission),
|
|
549
192
|
release: () => this.#releaseRequestAdmission(admission),
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
|
|
556
|
-
}
|
|
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;
|
|
588
|
-
}
|
|
589
|
-
#startHttpServer(operation, createServer) {
|
|
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";
|
|
597
|
-
const handleRequest = async () => {
|
|
193
|
+
// Whether handle(method, path) would reach a route, answered from this
|
|
194
|
+
// admission's own snapshot by the resolver handle() itself uses, so a
|
|
195
|
+
// miss is never a false negative. The interceptor asks it to skip
|
|
196
|
+
// reading the body of a request that will pass through anyway.
|
|
197
|
+
hasRoute: (method, path) => {
|
|
598
198
|
try {
|
|
599
|
-
const
|
|
600
|
-
|
|
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
|
-
}
|
|
619
|
-
};
|
|
620
|
-
handleRequest().catch((error) => {
|
|
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
|
-
}
|
|
199
|
+
const resolution = this.#resolveRoute(method, canonicalizePath(path), admission);
|
|
200
|
+
return resolution.kind === "match";
|
|
659
201
|
}
|
|
660
202
|
catch {
|
|
661
|
-
|
|
203
|
+
// Whatever made the resolver throw, handle() must answer it.
|
|
204
|
+
return true;
|
|
662
205
|
}
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
this.#rejectServerStart(operation, error);
|
|
206
|
+
},
|
|
207
|
+
// handle() above normalizes every response for its method, so the
|
|
208
|
+
// interceptor may send one on without a second normalizing pass.
|
|
209
|
+
[NORMALIZED_ADMISSION_KEY]: true,
|
|
668
210
|
};
|
|
669
|
-
|
|
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
|
-
}
|
|
677
|
-
const addr = httpServer.address();
|
|
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);
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
catch (error) {
|
|
690
|
-
httpServer.off("error", handleStartupError);
|
|
691
|
-
this.#rejectServerStart(operation, error);
|
|
692
|
-
}
|
|
211
|
+
return admitted;
|
|
693
212
|
}
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
}
|
|
734
|
-
});
|
|
213
|
+
// ===== Standalone Server =====
|
|
214
|
+
listen(port = 0, hostname = "127.0.0.1") {
|
|
215
|
+
return this.nodeServer.listen(port, hostname);
|
|
735
216
|
}
|
|
736
217
|
close() {
|
|
737
|
-
this
|
|
738
|
-
const server = this.server;
|
|
739
|
-
if (!server)
|
|
740
|
-
return;
|
|
741
|
-
this.server = undefined;
|
|
742
|
-
this.#beginServerClose(server);
|
|
743
|
-
this.logger.log("server", "Server stopped");
|
|
218
|
+
this.nodeServer.close();
|
|
744
219
|
}
|
|
745
220
|
// ===== Fetch Interceptor =====
|
|
746
221
|
intercept(options) {
|
|
@@ -749,7 +224,13 @@ export class CallableMockInstance {
|
|
|
749
224
|
// slot with their own options, released independently. The owner symbol
|
|
750
225
|
// keeps them one mock for dispatch, so a single request reaches handle()
|
|
751
226
|
// once no matter how many leases this instance holds.
|
|
752
|
-
const lease =
|
|
227
|
+
const lease = createFetchLease({
|
|
228
|
+
handle: (method, path, opts) => this.handle(method, path, opts),
|
|
229
|
+
options,
|
|
230
|
+
admitRequest: () => this.createRequestAdmission(),
|
|
231
|
+
owner: this.interceptOwner,
|
|
232
|
+
observe: () => this.#openExchangeObservation(),
|
|
233
|
+
});
|
|
753
234
|
const handle = {
|
|
754
235
|
restore: () => {
|
|
755
236
|
lease.restore();
|
|
@@ -768,6 +249,28 @@ export class CallableMockInstance {
|
|
|
768
249
|
this.logger.log("lifecycle", `Interception lease acquired (${this.interceptHandles.size} held)`);
|
|
769
250
|
return handle;
|
|
770
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Called by the lease right before it consults this mock about one request.
|
|
254
|
+
* It captures the plugins and the generation the request is admitted under,
|
|
255
|
+
* so observers piped later, or retired by reset(), never see it.
|
|
256
|
+
*/
|
|
257
|
+
#openExchangeObservation() {
|
|
258
|
+
const plugins = this.plugins; // replaced, never mutated
|
|
259
|
+
if (!hasExchangeObserver(plugins))
|
|
260
|
+
return undefined; // no exchange is built
|
|
261
|
+
const generation = this.generations.current;
|
|
262
|
+
// Same gate as events and history, checked before each observer: one
|
|
263
|
+
// observer may reset() the mock and uninstall the ones after it.
|
|
264
|
+
return (exchange) => {
|
|
265
|
+
runExchangeHooks({
|
|
266
|
+
plugins,
|
|
267
|
+
exchange,
|
|
268
|
+
logger: this.logger,
|
|
269
|
+
isLive: () => this.generations.isCurrent(generation),
|
|
270
|
+
});
|
|
271
|
+
};
|
|
272
|
+
}
|
|
273
|
+
// ===== Request Handling =====
|
|
771
274
|
async handle(method, path, options, admission) {
|
|
772
275
|
const requestAdmission = admission ?? this.#captureRequestAdmission();
|
|
773
276
|
try {
|
|
@@ -783,342 +286,360 @@ export class CallableMockInstance {
|
|
|
783
286
|
// literal unicode, and every lifecycle event, log line and 404 message must
|
|
784
287
|
// report the same spelling.
|
|
785
288
|
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
289
|
const signal = options?.signal;
|
|
796
290
|
throwIfAborted(signal);
|
|
797
|
-
const
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
291
|
+
const scope = {
|
|
292
|
+
method,
|
|
293
|
+
path,
|
|
294
|
+
admission,
|
|
295
|
+
signal,
|
|
296
|
+
handleStart: performance.now(),
|
|
297
|
+
requestId: this.globalConfig.debug ? crypto.randomUUID() : "",
|
|
298
|
+
query: { ...(options?.query ?? {}) },
|
|
299
|
+
headers: { ...(options?.headers ?? {}) },
|
|
300
|
+
body: options?.body,
|
|
301
|
+
};
|
|
302
|
+
const { requestId } = scope;
|
|
802
303
|
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
803
|
-
headers:
|
|
804
|
-
query:
|
|
304
|
+
headers: redactHeaders(scope.headers),
|
|
305
|
+
query: scope.query,
|
|
805
306
|
// Presence, not truthiness: "", 0 and false are bodies too.
|
|
806
307
|
bodyType: options !== undefined && "body" in options && options.body !== undefined
|
|
807
308
|
? typeof options.body
|
|
808
309
|
: "none",
|
|
809
310
|
});
|
|
810
311
|
this.logger.time(`request-${requestId}`);
|
|
811
|
-
if (this.requestGeneration
|
|
812
|
-
this.emit("request:start", {
|
|
312
|
+
if (this.generations.isCurrent(admission.requestGeneration)) {
|
|
313
|
+
this.events.emit("request:start", {
|
|
813
314
|
method,
|
|
814
315
|
path,
|
|
815
|
-
headers:
|
|
316
|
+
headers: scope.headers,
|
|
816
317
|
});
|
|
817
318
|
}
|
|
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 = {};
|
|
823
319
|
try {
|
|
824
|
-
|
|
825
|
-
if (
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
//
|
|
830
|
-
//
|
|
831
|
-
|
|
832
|
-
pathToCheck.startsWith(normalizedNamespace.endsWith("/")
|
|
833
|
-
? normalizedNamespace
|
|
834
|
-
: `${normalizedNamespace}/`);
|
|
835
|
-
if (!isMatch) {
|
|
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({
|
|
840
|
-
method,
|
|
841
|
-
path,
|
|
842
|
-
requestId,
|
|
843
|
-
handleStart,
|
|
844
|
-
requestGeneration,
|
|
845
|
-
});
|
|
846
|
-
}
|
|
847
|
-
// Remove namespace prefix, ensuring we always start with /
|
|
848
|
-
const stripped = pathToCheck.slice(normalizedNamespace.length);
|
|
849
|
-
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
320
|
+
const resolution = this.#resolveRoute(method, path, admission);
|
|
321
|
+
if (resolution.kind !== "match") {
|
|
322
|
+
this.logger.log("route", resolution.kind === "outside-namespace"
|
|
323
|
+
? `[${requestId}] Path doesn't match namespace ${resolution.namespacePath}`
|
|
324
|
+
: `[${requestId}] No route found for ${method} ${resolution.requestPath}`);
|
|
325
|
+
// A request outside the namespace is a route miss like any other, so
|
|
326
|
+
// it reports one instead of silently ending.
|
|
327
|
+
return this.#finalizeMiss(scope);
|
|
850
328
|
}
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
// Find matching route
|
|
856
|
-
matchedRoute = findRoute(method, requestPath, requestStaticRoutes, requestRoutes);
|
|
857
|
-
if (!matchedRoute) {
|
|
858
|
-
this.logger.log("route", `[${requestId}] No route found for ${method} ${requestPath}`);
|
|
859
|
-
return this.#finalizeMiss({
|
|
860
|
-
method,
|
|
861
|
-
path,
|
|
862
|
-
requestId,
|
|
863
|
-
handleStart,
|
|
864
|
-
requestGeneration,
|
|
865
|
-
});
|
|
866
|
-
}
|
|
867
|
-
this.logger.log("route", `[${requestId}] Matched route: ${method} ${matchedRoute.path}`);
|
|
868
|
-
// Extract parameters from the matched route
|
|
869
|
-
params = extractParams(matchedRoute, requestPath);
|
|
870
|
-
if (this.requestGeneration === requestGeneration) {
|
|
871
|
-
this.emit("request:match", {
|
|
329
|
+
const match = this.#bindRoute(scope, resolution);
|
|
330
|
+
scope.match = match;
|
|
331
|
+
if (this.generations.isCurrent(admission.requestGeneration)) {
|
|
332
|
+
this.events.emit("request:match", {
|
|
872
333
|
method,
|
|
873
334
|
// Every lifecycle event carries the ORIGINAL request path; the
|
|
874
335
|
// namespace-stripped route form is exposed as routePath.
|
|
875
336
|
path,
|
|
876
|
-
routePath:
|
|
877
|
-
params,
|
|
337
|
+
routePath: match.route.path,
|
|
338
|
+
params: match.params,
|
|
878
339
|
});
|
|
879
340
|
}
|
|
880
341
|
throwIfAborted(signal);
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
path: requestPath,
|
|
885
|
-
route: matchedRoute.config,
|
|
886
|
-
method,
|
|
887
|
-
params,
|
|
888
|
-
query: reqQuery,
|
|
889
|
-
headers: reqHeaders,
|
|
890
|
-
body: requestBody,
|
|
891
|
-
state: new Map(),
|
|
892
|
-
routeState: requestState,
|
|
893
|
-
signal,
|
|
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 };
|
|
342
|
+
let draft = await this.#runPreflight(scope, match);
|
|
343
|
+
if (draft.result === undefined) {
|
|
344
|
+
draft = await this.#runGenerator(scope, match, draft);
|
|
900
345
|
}
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
346
|
+
const response = await this.#runResponsePipeline(scope, match, draft);
|
|
347
|
+
await this.#finalizeMatchedRequest(scope, response);
|
|
348
|
+
return response;
|
|
349
|
+
}
|
|
350
|
+
catch (error) {
|
|
351
|
+
return await this.#answerFailedRequest(scope, error);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Where a request path lands in the admission's route table: outside the
|
|
356
|
+
* namespace, on no route, or on a route. It only resolves — no logs, no
|
|
357
|
+
* events — so `handle()` and the admission's route probe share it and can
|
|
358
|
+
* never disagree. `path` must already be canonical.
|
|
359
|
+
*/
|
|
360
|
+
#resolveRoute(method, path, admission) {
|
|
361
|
+
let requestPath = path;
|
|
362
|
+
// Apply namespace if configured. A root namespace ("/") parses to the
|
|
363
|
+
// empty prefix and strips nothing.
|
|
364
|
+
const namespacePrefix = admission.namespace
|
|
365
|
+
? this.#namespacePrefix(admission.namespace)
|
|
366
|
+
: undefined;
|
|
367
|
+
if (namespacePrefix !== undefined && namespacePrefix.path !== "") {
|
|
368
|
+
const pathToCheck = path.startsWith("/") ? path : `/${path}`;
|
|
369
|
+
// Segment-boundary match: "/api" serves "/api" and "/api/users" but
|
|
370
|
+
// not "/apiv2". The trailing-slash rule is parsePathPrefix's, shared
|
|
371
|
+
// with intercept({ baseUrl }): "/api/" is the same namespace as "/api".
|
|
372
|
+
if (!matchPathPrefix(namespacePrefix, pathToCheck)) {
|
|
373
|
+
return {
|
|
374
|
+
kind: "outside-namespace",
|
|
375
|
+
namespacePath: namespacePrefix.path,
|
|
914
376
|
};
|
|
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
377
|
}
|
|
933
|
-
//
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
378
|
+
// Remove namespace prefix, ensuring we always start with /
|
|
379
|
+
const stripped = pathToCheck.slice(namespacePrefix.path.length);
|
|
380
|
+
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
381
|
+
}
|
|
382
|
+
// One trailing-slash normalization for the whole request: route lookup
|
|
383
|
+
// and parameter extraction must see the identical string, or a request
|
|
384
|
+
// could match a route and then capture no parameters.
|
|
385
|
+
requestPath = normalizePath(requestPath);
|
|
386
|
+
const route = findRoute(method, requestPath, admission.routes.staticRoutes, admission.routes.routes);
|
|
387
|
+
return route
|
|
388
|
+
? { kind: "match", route, requestPath }
|
|
389
|
+
: { kind: "no-route", requestPath };
|
|
390
|
+
}
|
|
391
|
+
/**
|
|
392
|
+
* Bind a request to the route it matched: its parameters, what history
|
|
393
|
+
* will record, and its own copy of the route config.
|
|
394
|
+
*/
|
|
395
|
+
#bindRoute(scope, resolution) {
|
|
396
|
+
const { route, requestPath } = resolution;
|
|
397
|
+
this.logger.log("route", `[${scope.requestId}] Matched route: ${scope.method} ${route.path}`);
|
|
398
|
+
const params = extractParams(route, requestPath);
|
|
399
|
+
return {
|
|
400
|
+
route,
|
|
401
|
+
requestPath,
|
|
402
|
+
params,
|
|
403
|
+
// History reports what the CLIENT sent, so it is captured here, before
|
|
404
|
+
// any plugin or the generator gets the live objects and can edit them.
|
|
405
|
+
historyParams: { ...params },
|
|
406
|
+
historySnapshot: this.requestHistory.snapshotRequest(scope),
|
|
407
|
+
// A per-request copy: a plugin that edits `context.route` in place
|
|
408
|
+
// changes this request only, never the registered route.
|
|
409
|
+
routeConfig: copyRouteConfig(route.config),
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* Build the plugin context and run the plugins' `beforeRequest` guards, so
|
|
414
|
+
* they can reject invalid or unauthorized requests before any route code
|
|
415
|
+
* runs.
|
|
416
|
+
*/
|
|
417
|
+
async #runPreflight(scope, match) {
|
|
418
|
+
const { signal } = scope;
|
|
419
|
+
const pluginContext = {
|
|
420
|
+
path: match.requestPath,
|
|
421
|
+
route: match.routeConfig,
|
|
422
|
+
method: scope.method,
|
|
423
|
+
params: match.params,
|
|
424
|
+
query: scope.query,
|
|
425
|
+
headers: scope.headers,
|
|
426
|
+
body: scope.body,
|
|
427
|
+
state: new Map(),
|
|
428
|
+
routeState: scope.admission.state,
|
|
429
|
+
signal,
|
|
430
|
+
};
|
|
431
|
+
const preflight = await runPluginBeforeRequest(scope.admission.plugins, pluginContext, this.logger, signal);
|
|
432
|
+
throwIfAborted(signal);
|
|
433
|
+
return {
|
|
434
|
+
context: preflight.requestShortCircuited === true
|
|
435
|
+
? { ...preflight.context, requestShortCircuited: true }
|
|
436
|
+
: preflight.context,
|
|
437
|
+
result: preflight.response,
|
|
438
|
+
recovered: preflight.recoveredFromError === true,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Produce the route's response, and give the plugins' `onError` hooks the
|
|
443
|
+
* chance to recover when the generator throws.
|
|
444
|
+
*/
|
|
445
|
+
async #runGenerator(scope, match, draft) {
|
|
446
|
+
const { signal } = scope;
|
|
447
|
+
const { context: pluginContext } = draft;
|
|
448
|
+
const plugins = scope.admission.plugins;
|
|
449
|
+
const context = {
|
|
450
|
+
method: pluginContext.method,
|
|
451
|
+
path: pluginContext.path,
|
|
452
|
+
params: pluginContext.params,
|
|
453
|
+
query: pluginContext.query,
|
|
454
|
+
headers: pluginContext.headers,
|
|
455
|
+
body: pluginContext.body,
|
|
456
|
+
state: pluginContext.routeState ?? scope.admission.state,
|
|
457
|
+
pluginState: pluginContext.state,
|
|
458
|
+
signal,
|
|
459
|
+
};
|
|
460
|
+
try {
|
|
461
|
+
let result;
|
|
462
|
+
if (isGeneratorFunction(match.route.generator)) {
|
|
463
|
+
result = await awaitWithAbort(match.route.generator(context), signal);
|
|
944
464
|
}
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
465
|
+
else {
|
|
466
|
+
// Static data is one object shared by every request; plugins get
|
|
467
|
+
// their own copy so an in-place edit cannot leak into the next
|
|
468
|
+
// response (or back into the caller's object).
|
|
469
|
+
result =
|
|
470
|
+
plugins.length > 0
|
|
471
|
+
? copyStaticData(match.route.generator)
|
|
472
|
+
: match.route.generator;
|
|
948
473
|
}
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
await this.#finalizeMatchedRequest({
|
|
952
|
-
method,
|
|
953
|
-
path,
|
|
954
|
-
requestPath,
|
|
955
|
-
params,
|
|
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,
|
|
969
|
-
});
|
|
970
|
-
return response;
|
|
474
|
+
throwIfAborted(signal);
|
|
475
|
+
return { ...draft, result };
|
|
971
476
|
}
|
|
972
477
|
catch (error) {
|
|
973
478
|
throwIfAborted(signal);
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
|
|
982
|
-
},
|
|
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({
|
|
988
|
-
method,
|
|
989
|
-
path,
|
|
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,
|
|
1005
|
-
});
|
|
1006
|
-
return errorResponse;
|
|
479
|
+
const recovery = await recoverGeneratorError(plugins, pluginContext, error, this.logger, signal);
|
|
480
|
+
throwIfAborted(signal);
|
|
481
|
+
return {
|
|
482
|
+
context: recovery.context,
|
|
483
|
+
result: recovery.response,
|
|
484
|
+
recovered: recovery.recoveredFromError === true,
|
|
485
|
+
};
|
|
1007
486
|
}
|
|
1008
487
|
}
|
|
1009
488
|
/**
|
|
1010
|
-
*
|
|
489
|
+
* Run the plugins' response processors (skipped after an error recovery)
|
|
490
|
+
* and turn the result into the normalized response.
|
|
491
|
+
*/
|
|
492
|
+
async #runResponsePipeline(scope, match, draft) {
|
|
493
|
+
let result = draft.result;
|
|
494
|
+
try {
|
|
495
|
+
if (draft.recovered) {
|
|
496
|
+
this.logger.log("pipeline", "Skipping response processors after error recovery");
|
|
497
|
+
}
|
|
498
|
+
else {
|
|
499
|
+
const pipelineResult = await runPluginPipeline(scope.admission.plugins, draft.context, result, this.logger, scope.signal);
|
|
500
|
+
throwIfAborted(scope.signal);
|
|
501
|
+
result = pipelineResult.response;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
catch (error) {
|
|
505
|
+
this.logger.log("error", `[${scope.requestId}] Plugin pipeline error: ${errorMessage(error)}`);
|
|
506
|
+
throw error;
|
|
507
|
+
}
|
|
508
|
+
return normalizeResponse(parseResponse(result, match.routeConfig), scope.method);
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Answer a request that failed after `request:start` with a marked 500.
|
|
512
|
+
* Every such exit ends with exactly one `request:end`; a cancelled request
|
|
513
|
+
* reports 499 (client closed request) before its abort reason propagates.
|
|
514
|
+
*/
|
|
515
|
+
async #answerFailedRequest(scope, error) {
|
|
516
|
+
this.#throwIfRequestAborted(scope);
|
|
517
|
+
this.logger.log("error", `[${scope.requestId}] Error processing request: ${errorMessage(error)}`, error);
|
|
518
|
+
const responseError = error instanceof Error ? error : new Error(errorMessage(error));
|
|
519
|
+
const errorResponse = markResponseException(buildJsonErrorResponse({
|
|
520
|
+
status: 500,
|
|
521
|
+
error: responseError.message,
|
|
522
|
+
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
|
|
523
|
+
method: scope.method,
|
|
524
|
+
}), responseError);
|
|
525
|
+
// A request that matched a route did happen: it is finalized exactly like
|
|
526
|
+
// a successful one — its own delay override, and a history record.
|
|
527
|
+
try {
|
|
528
|
+
await this.#finalizeMatchedRequest(scope, errorResponse);
|
|
529
|
+
}
|
|
530
|
+
catch (finalizeError) {
|
|
531
|
+
// Only the delay can reject here, and only with the abort reason.
|
|
532
|
+
this.#throwIfRequestAborted(scope);
|
|
533
|
+
throw finalizeError;
|
|
534
|
+
}
|
|
535
|
+
return errorResponse;
|
|
536
|
+
}
|
|
537
|
+
/**
|
|
538
|
+
* Finish a request after `request:start`, matched or failed.
|
|
1011
539
|
*
|
|
1012
540
|
* Order matters: delay first (an abort during it must escape before anything
|
|
1013
541
|
* is committed), then the history record, then `request:end`, then the logs.
|
|
1014
542
|
*/
|
|
1015
|
-
async #finalizeMatchedRequest(
|
|
1016
|
-
const {
|
|
543
|
+
async #finalizeMatchedRequest(scope, response) {
|
|
544
|
+
const { admission, match, signal } = scope;
|
|
1017
545
|
// Apply delay (route-level overrides global)
|
|
1018
|
-
await
|
|
1019
|
-
|
|
546
|
+
await applyResponseDelay({
|
|
547
|
+
routeDelay: match?.routeConfig.delay,
|
|
548
|
+
globalDelay: admission.globalDelay,
|
|
549
|
+
signal,
|
|
550
|
+
});
|
|
551
|
+
throwIfAborted(signal);
|
|
1020
552
|
// Record request in history (FIFO-bounded when maxHistorySize is set)
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
this.requestHistory.
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
timestamp: Date.now(),
|
|
1033
|
-
response: {
|
|
1034
|
-
status: response.status,
|
|
1035
|
-
body: snapshotHistoryValue(response.body),
|
|
1036
|
-
},
|
|
553
|
+
const historySnapshot = match?.historySnapshot;
|
|
554
|
+
if (match !== undefined &&
|
|
555
|
+
historySnapshot !== undefined &&
|
|
556
|
+
this.generations.isCurrent(admission.requestGeneration)) {
|
|
557
|
+
this.requestHistory.record({
|
|
558
|
+
generation: admission.historyGeneration,
|
|
559
|
+
method: scope.method,
|
|
560
|
+
path: match.requestPath,
|
|
561
|
+
params: match.historyParams,
|
|
562
|
+
snapshot: historySnapshot,
|
|
563
|
+
response,
|
|
1037
564
|
});
|
|
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
565
|
}
|
|
1045
|
-
if (this.
|
|
1046
|
-
this.emit("request:end", {
|
|
1047
|
-
method:
|
|
1048
|
-
path:
|
|
566
|
+
if (this.generations.isCurrent(admission.requestGeneration)) {
|
|
567
|
+
this.events.emit("request:end", {
|
|
568
|
+
method: scope.method,
|
|
569
|
+
path: scope.path,
|
|
1049
570
|
status: response.status,
|
|
1050
|
-
duration: performance.now() -
|
|
571
|
+
duration: performance.now() - scope.handleStart,
|
|
1051
572
|
});
|
|
1052
573
|
}
|
|
1053
|
-
this.logger.log("response", `[${
|
|
574
|
+
this.logger.log("response", `[${scope.requestId}] Sending response ${response.status}`, {
|
|
1054
575
|
status: response.status,
|
|
1055
|
-
headers:
|
|
576
|
+
headers: redactHeaders(response.headers),
|
|
1056
577
|
bodyType: typeof response.body,
|
|
1057
578
|
});
|
|
1058
|
-
this.logger.timeEnd(`request-${
|
|
579
|
+
this.logger.timeEnd(`request-${scope.requestId}`);
|
|
580
|
+
}
|
|
581
|
+
/**
|
|
582
|
+
* Rethrow a cancellation after reporting it as the request's terminal
|
|
583
|
+
* event, so a `request:start` listener always sees exactly one
|
|
584
|
+
* `request:end`. 499 is the de facto "client closed request" status.
|
|
585
|
+
*/
|
|
586
|
+
#throwIfRequestAborted(scope) {
|
|
587
|
+
const { signal } = scope;
|
|
588
|
+
if (!signal?.aborted)
|
|
589
|
+
return;
|
|
590
|
+
if (this.generations.isCurrent(scope.admission.requestGeneration)) {
|
|
591
|
+
this.events.emit("request:end", {
|
|
592
|
+
method: scope.method,
|
|
593
|
+
path: scope.path,
|
|
594
|
+
status: ABORTED_REQUEST_STATUS,
|
|
595
|
+
duration: performance.now() - scope.handleStart,
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
throwIfAborted(signal);
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* The namespace as the path prefix request paths are compared with:
|
|
602
|
+
* percent-encoded, with a leading slash and without a trailing one, so
|
|
603
|
+
* `"/api/"` and `"/api"` behave identically (both serve `/api`, neither
|
|
604
|
+
* serves `/api//users`). `""` for a root namespace. Only the path of an
|
|
605
|
+
* origin-form namespace is used. Cached per namespace string instead of
|
|
606
|
+
* re-parsed per request.
|
|
607
|
+
*/
|
|
608
|
+
#namespacePrefix(namespace) {
|
|
609
|
+
const cached = this.namespaceCache;
|
|
610
|
+
if (cached?.raw === namespace)
|
|
611
|
+
return cached.prefix;
|
|
612
|
+
const prefix = parsePathPrefix(namespace);
|
|
613
|
+
this.namespaceCache = { raw: namespace, prefix };
|
|
614
|
+
return prefix;
|
|
1059
615
|
}
|
|
1060
616
|
/**
|
|
1061
617
|
* Finish a request that matched no route — an unknown path or one outside the
|
|
1062
618
|
* configured namespace. Misses stay delay-free and out of history: nothing
|
|
1063
619
|
* ran, so there is nothing to record.
|
|
1064
620
|
*/
|
|
1065
|
-
#finalizeMiss(
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
const
|
|
1073
|
-
const response = markRouteNotFound(normalizeResponse({
|
|
621
|
+
#finalizeMiss(scope) {
|
|
622
|
+
const { method, path } = scope;
|
|
623
|
+
// Checked before each event: a listener may reset the mock in between.
|
|
624
|
+
if (this.generations.isCurrent(scope.admission.requestGeneration)) {
|
|
625
|
+
this.events.emit("request:notfound", { method, path });
|
|
626
|
+
}
|
|
627
|
+
const error = new RouteNotFoundError(method, path);
|
|
628
|
+
const response = markRouteNotFound(buildJsonErrorResponse({
|
|
1074
629
|
status: 404,
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
630
|
+
error: error.message,
|
|
631
|
+
code: error.code,
|
|
632
|
+
method,
|
|
633
|
+
}));
|
|
634
|
+
if (this.generations.isCurrent(scope.admission.requestGeneration)) {
|
|
635
|
+
this.events.emit("request:end", {
|
|
636
|
+
method,
|
|
637
|
+
path,
|
|
1082
638
|
status: 404,
|
|
1083
|
-
duration: performance.now() -
|
|
639
|
+
duration: performance.now() - scope.handleStart,
|
|
1084
640
|
});
|
|
1085
641
|
}
|
|
1086
|
-
this.logger.timeEnd(`request-${
|
|
642
|
+
this.logger.timeEnd(`request-${scope.requestId}`);
|
|
1087
643
|
return response;
|
|
1088
644
|
}
|
|
1089
|
-
/**
|
|
1090
|
-
* Apply configured response delay
|
|
1091
|
-
* Supports both fixed delays and random delays within a range
|
|
1092
|
-
* @private
|
|
1093
|
-
*/
|
|
1094
|
-
async applyDelay(routeDelay, globalDelay, signal) {
|
|
1095
|
-
const effectiveDelay = routeDelay ?? globalDelay;
|
|
1096
|
-
if (!effectiveDelay) {
|
|
1097
|
-
throwIfAborted(signal);
|
|
1098
|
-
return;
|
|
1099
|
-
}
|
|
1100
|
-
const configuredMs = Array.isArray(effectiveDelay)
|
|
1101
|
-
? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
|
|
1102
|
-
effectiveDelay[0]
|
|
1103
|
-
: effectiveDelay;
|
|
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
|
-
});
|
|
1123
|
-
}
|
|
1124
645
|
}
|