@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/src/builder.ts
DELETED
|
@@ -1,701 +0,0 @@
|
|
|
1
|
-
import type { Server } from "node:http";
|
|
2
|
-
import { normalizePath, toHttpMethod } from "./constants.js";
|
|
3
|
-
import {
|
|
4
|
-
errorMessage,
|
|
5
|
-
RouteDefinitionError,
|
|
6
|
-
RouteNotFoundError,
|
|
7
|
-
SchmockError,
|
|
8
|
-
} from "./errors.js";
|
|
9
|
-
import {
|
|
10
|
-
collectBody,
|
|
11
|
-
parseNodeHeaders,
|
|
12
|
-
parseNodeQuery,
|
|
13
|
-
writeSchmockResponse,
|
|
14
|
-
} from "./http-helpers.js";
|
|
15
|
-
import { createFetchInterceptor } from "./interceptor.js";
|
|
16
|
-
import { parseRouteKey } from "./parser.js";
|
|
17
|
-
import { runPluginPipeline } from "./plugin-pipeline.js";
|
|
18
|
-
import { parseResponse } from "./response-parser.js";
|
|
19
|
-
import type { CompiledCallableRoute } from "./route-matcher.js";
|
|
20
|
-
import {
|
|
21
|
-
extractParams,
|
|
22
|
-
findRoute,
|
|
23
|
-
isGeneratorFunction,
|
|
24
|
-
} from "./route-matcher.js";
|
|
25
|
-
|
|
26
|
-
/**
|
|
27
|
-
* Debug logger that respects debug mode configuration
|
|
28
|
-
*/
|
|
29
|
-
class DebugLogger {
|
|
30
|
-
constructor(private enabled = false) {}
|
|
31
|
-
|
|
32
|
-
log(category: string, message: string, data?: unknown) {
|
|
33
|
-
if (!this.enabled) return;
|
|
34
|
-
|
|
35
|
-
const timestamp = new Date().toISOString();
|
|
36
|
-
const prefix = `[${timestamp}] [SCHMOCK:${category.toUpperCase()}]`;
|
|
37
|
-
|
|
38
|
-
if (data) {
|
|
39
|
-
console.log(`${prefix} ${message}`, data);
|
|
40
|
-
} else {
|
|
41
|
-
console.log(`${prefix} ${message}`);
|
|
42
|
-
}
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
time(label: string) {
|
|
46
|
-
if (!this.enabled) return;
|
|
47
|
-
console.time(`[SCHMOCK] ${label}`);
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
timeEnd(label: string) {
|
|
51
|
-
if (!this.enabled) return;
|
|
52
|
-
console.timeEnd(`[SCHMOCK] ${label}`);
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
/**
|
|
57
|
-
* Callable mock instance that implements the new API.
|
|
58
|
-
*
|
|
59
|
-
* @internal
|
|
60
|
-
*/
|
|
61
|
-
export class CallableMockInstance {
|
|
62
|
-
private routes: CompiledCallableRoute[] = [];
|
|
63
|
-
private staticRoutes = new Map<string, CompiledCallableRoute>();
|
|
64
|
-
private plugins: Schmock.Plugin[] = [];
|
|
65
|
-
private logger: DebugLogger;
|
|
66
|
-
private requestHistory: Schmock.RequestRecord[] = [];
|
|
67
|
-
private callableRef: Schmock.CallableMockInstance | undefined;
|
|
68
|
-
private server: Server | undefined;
|
|
69
|
-
private serverInfo: Schmock.ServerInfo | undefined;
|
|
70
|
-
private interceptHandle: Schmock.InterceptHandle | null = null;
|
|
71
|
-
// biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
|
|
72
|
-
private listeners = new Map<string, Set<Function>>();
|
|
73
|
-
|
|
74
|
-
constructor(private globalConfig: Schmock.GlobalConfig = {}) {
|
|
75
|
-
this.logger = new DebugLogger(globalConfig.debug || false);
|
|
76
|
-
if (globalConfig.debug) {
|
|
77
|
-
this.logger.log("config", "Debug mode enabled");
|
|
78
|
-
}
|
|
79
|
-
this.logger.log("config", "Callable mock instance created", {
|
|
80
|
-
debug: globalConfig.debug,
|
|
81
|
-
namespace: globalConfig.namespace,
|
|
82
|
-
delay: globalConfig.delay,
|
|
83
|
-
});
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Method for defining routes (called when instance is invoked)
|
|
87
|
-
defineRoute(
|
|
88
|
-
route: Schmock.RouteKey,
|
|
89
|
-
generator: Schmock.Generator,
|
|
90
|
-
config: Schmock.RouteConfig,
|
|
91
|
-
): this {
|
|
92
|
-
// FIX 1.2: shallow-clone the caller's config so mutations below stay private
|
|
93
|
-
const routeConfig = { ...config };
|
|
94
|
-
|
|
95
|
-
// Auto-detect contentType if not provided
|
|
96
|
-
if (!routeConfig.contentType) {
|
|
97
|
-
if (typeof generator === "function") {
|
|
98
|
-
// Default to JSON for function generators
|
|
99
|
-
routeConfig.contentType = "application/json";
|
|
100
|
-
} else if (
|
|
101
|
-
typeof generator === "string" ||
|
|
102
|
-
typeof generator === "number" ||
|
|
103
|
-
typeof generator === "boolean"
|
|
104
|
-
) {
|
|
105
|
-
// Default to plain text for primitives
|
|
106
|
-
routeConfig.contentType = "text/plain";
|
|
107
|
-
} else if (Buffer.isBuffer(generator)) {
|
|
108
|
-
// Default to octet-stream for buffers
|
|
109
|
-
routeConfig.contentType = "application/octet-stream";
|
|
110
|
-
} else {
|
|
111
|
-
// Default to JSON for objects/arrays
|
|
112
|
-
routeConfig.contentType = "application/json";
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// Validate generator matches contentType if it's static data
|
|
117
|
-
if (
|
|
118
|
-
typeof generator !== "function" &&
|
|
119
|
-
routeConfig.contentType === "application/json"
|
|
120
|
-
) {
|
|
121
|
-
try {
|
|
122
|
-
JSON.stringify(generator);
|
|
123
|
-
} catch (_error) {
|
|
124
|
-
throw new RouteDefinitionError(
|
|
125
|
-
route,
|
|
126
|
-
"Generator data is not valid JSON but contentType is application/json",
|
|
127
|
-
);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
|
|
131
|
-
// Parse the route key to create pattern and extract parameters
|
|
132
|
-
const parsed = parseRouteKey(route);
|
|
133
|
-
|
|
134
|
-
// FIX 2.2: normalize paths before duplicate check so /users and /users/ are
|
|
135
|
-
// treated as the same route (consistent with the static-route Map key below)
|
|
136
|
-
const normalizedParsedPath = normalizePath(parsed.path);
|
|
137
|
-
const existing = this.routes.find(
|
|
138
|
-
(r) =>
|
|
139
|
-
r.method === parsed.method &&
|
|
140
|
-
normalizePath(r.path) === normalizedParsedPath,
|
|
141
|
-
);
|
|
142
|
-
if (existing) {
|
|
143
|
-
this.logger.log(
|
|
144
|
-
"warning",
|
|
145
|
-
`Duplicate route: ${route} — first registration wins`,
|
|
146
|
-
);
|
|
147
|
-
return this;
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
// Compile the route
|
|
151
|
-
const compiledRoute: CompiledCallableRoute = {
|
|
152
|
-
pattern: parsed.pattern,
|
|
153
|
-
params: parsed.params,
|
|
154
|
-
method: parsed.method,
|
|
155
|
-
path: parsed.path,
|
|
156
|
-
generator,
|
|
157
|
-
config: routeConfig,
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
this.routes.push(compiledRoute);
|
|
161
|
-
|
|
162
|
-
// Store static routes (no params) in Map for O(1) lookup
|
|
163
|
-
if (parsed.params.length === 0) {
|
|
164
|
-
const key = `${parsed.method} ${normalizePath(parsed.path)}`;
|
|
165
|
-
this.staticRoutes.set(key, compiledRoute);
|
|
166
|
-
}
|
|
167
|
-
|
|
168
|
-
this.logger.log("route", `Route defined: ${route}`, {
|
|
169
|
-
contentType: routeConfig.contentType,
|
|
170
|
-
generatorType: typeof generator,
|
|
171
|
-
hasParams: parsed.params.length > 0,
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
return this;
|
|
175
|
-
}
|
|
176
|
-
|
|
177
|
-
setCallableRef(ref: Schmock.CallableMockInstance): void {
|
|
178
|
-
this.callableRef = ref;
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
pipe(plugin: Schmock.Plugin): this {
|
|
182
|
-
this.plugins.push(plugin);
|
|
183
|
-
this.logger.log(
|
|
184
|
-
"plugin",
|
|
185
|
-
`Registered plugin: ${plugin.name}@${plugin.version || "unknown"}`,
|
|
186
|
-
{
|
|
187
|
-
name: plugin.name,
|
|
188
|
-
version: plugin.version,
|
|
189
|
-
hasProcess: typeof plugin.process === "function",
|
|
190
|
-
hasOnError: typeof plugin.onError === "function",
|
|
191
|
-
},
|
|
192
|
-
);
|
|
193
|
-
if (plugin.install && this.callableRef) {
|
|
194
|
-
plugin.install(this.callableRef);
|
|
195
|
-
}
|
|
196
|
-
return this;
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
// ===== Request Spy / History API =====
|
|
200
|
-
|
|
201
|
-
/**
|
|
202
|
-
* FIX 2.3: Deep-clone a request record so callers cannot corrupt internal
|
|
203
|
-
* history by mutating nested body/response objects.
|
|
204
|
-
* Falls back to a shallow spread for bodies that are not structuredClone-able
|
|
205
|
-
* (e.g. Buffers, streams — rare in practice but defensively handled).
|
|
206
|
-
*/
|
|
207
|
-
private cloneRecord(r: Schmock.RequestRecord): Schmock.RequestRecord {
|
|
208
|
-
try {
|
|
209
|
-
return structuredClone(r);
|
|
210
|
-
} catch {
|
|
211
|
-
return {
|
|
212
|
-
...r,
|
|
213
|
-
response: { ...r.response },
|
|
214
|
-
};
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[] {
|
|
219
|
-
if (method || path) {
|
|
220
|
-
return this.requestHistory
|
|
221
|
-
.filter(
|
|
222
|
-
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
223
|
-
)
|
|
224
|
-
.map((r) => this.cloneRecord(r));
|
|
225
|
-
}
|
|
226
|
-
return this.requestHistory.map((r) => this.cloneRecord(r));
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
called(method?: Schmock.HttpMethod, path?: string): boolean {
|
|
230
|
-
if (method || path) {
|
|
231
|
-
return this.requestHistory.some(
|
|
232
|
-
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
233
|
-
);
|
|
234
|
-
}
|
|
235
|
-
return this.requestHistory.length > 0;
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
callCount(method?: Schmock.HttpMethod, path?: string): number {
|
|
239
|
-
if (method || path) {
|
|
240
|
-
return this.requestHistory.filter(
|
|
241
|
-
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
242
|
-
).length;
|
|
243
|
-
}
|
|
244
|
-
return this.requestHistory.length;
|
|
245
|
-
}
|
|
246
|
-
|
|
247
|
-
lastRequest(
|
|
248
|
-
method?: Schmock.HttpMethod,
|
|
249
|
-
path?: string,
|
|
250
|
-
): Schmock.RequestRecord | undefined {
|
|
251
|
-
if (method || path) {
|
|
252
|
-
const filtered = this.requestHistory.filter(
|
|
253
|
-
(r) => (!method || r.method === method) && (!path || r.path === path),
|
|
254
|
-
);
|
|
255
|
-
const last = filtered[filtered.length - 1];
|
|
256
|
-
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
257
|
-
return last ? this.cloneRecord(last) : undefined;
|
|
258
|
-
}
|
|
259
|
-
const last = this.requestHistory[this.requestHistory.length - 1];
|
|
260
|
-
// FIX 2.3: return a deep clone so callers cannot corrupt internal history
|
|
261
|
-
return last ? this.cloneRecord(last) : undefined;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
// ===== Introspection =====
|
|
265
|
-
|
|
266
|
-
getRoutes(): Schmock.RouteInfo[] {
|
|
267
|
-
return this.routes.map((r) => ({
|
|
268
|
-
method: r.method,
|
|
269
|
-
path: r.path,
|
|
270
|
-
hasParams: r.params.length > 0,
|
|
271
|
-
}));
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
getState(): Record<string, unknown> {
|
|
275
|
-
return { ...(this.globalConfig.state || {}) };
|
|
276
|
-
}
|
|
277
|
-
|
|
278
|
-
// ===== Lifecycle Events =====
|
|
279
|
-
|
|
280
|
-
on<E extends Schmock.SchmockEvent>(
|
|
281
|
-
event: E,
|
|
282
|
-
listener: (data: Schmock.SchmockEventMap[E]) => void,
|
|
283
|
-
): this {
|
|
284
|
-
let set = this.listeners.get(event);
|
|
285
|
-
if (!set) {
|
|
286
|
-
set = new Set();
|
|
287
|
-
this.listeners.set(event, set);
|
|
288
|
-
}
|
|
289
|
-
set.add(listener);
|
|
290
|
-
return this;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
off<E extends Schmock.SchmockEvent>(
|
|
294
|
-
event: E,
|
|
295
|
-
listener: (data: Schmock.SchmockEventMap[E]) => void,
|
|
296
|
-
): this {
|
|
297
|
-
this.listeners.get(event)?.delete(listener);
|
|
298
|
-
return this;
|
|
299
|
-
}
|
|
300
|
-
|
|
301
|
-
private emit(event: string, data: unknown): void {
|
|
302
|
-
const set = this.listeners.get(event);
|
|
303
|
-
if (set) {
|
|
304
|
-
for (const listener of set) {
|
|
305
|
-
listener(data);
|
|
306
|
-
}
|
|
307
|
-
}
|
|
308
|
-
}
|
|
309
|
-
|
|
310
|
-
// ===== Reset / Lifecycle =====
|
|
311
|
-
|
|
312
|
-
reset(): void {
|
|
313
|
-
this.interceptHandle?.restore();
|
|
314
|
-
this.interceptHandle = null;
|
|
315
|
-
this.close();
|
|
316
|
-
this.routes = [];
|
|
317
|
-
this.staticRoutes.clear();
|
|
318
|
-
this.plugins = [];
|
|
319
|
-
this.requestHistory = [];
|
|
320
|
-
this.listeners.clear();
|
|
321
|
-
// FIX 3.3: assign a fresh object instead of deleting keys off the caller's
|
|
322
|
-
// reference — this avoids mutating the external state object passed by the user
|
|
323
|
-
if (this.globalConfig.state) {
|
|
324
|
-
this.globalConfig.state = {};
|
|
325
|
-
}
|
|
326
|
-
this.logger.log("lifecycle", "Mock fully reset");
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
resetHistory(): void {
|
|
330
|
-
this.requestHistory = [];
|
|
331
|
-
this.logger.log("lifecycle", "Request history cleared");
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
resetState(): void {
|
|
335
|
-
// FIX 3.3: assign a fresh object instead of deleting keys off the caller's
|
|
336
|
-
// reference — this avoids mutating the external state object passed by the user
|
|
337
|
-
if (this.globalConfig.state) {
|
|
338
|
-
this.globalConfig.state = {};
|
|
339
|
-
}
|
|
340
|
-
this.logger.log("lifecycle", "State cleared");
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
// ===== Standalone Server =====
|
|
344
|
-
|
|
345
|
-
listen(port = 0, hostname = "127.0.0.1"): Promise<Schmock.ServerInfo> {
|
|
346
|
-
if (this.server) {
|
|
347
|
-
throw new SchmockError(
|
|
348
|
-
"Server is already running",
|
|
349
|
-
"SERVER_ALREADY_RUNNING",
|
|
350
|
-
);
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// Lazy-load node:http so browser bundles never pull it in. See issue #395.
|
|
354
|
-
return import("node:http").then(({ createServer }) =>
|
|
355
|
-
this.#startHttpServer(createServer, port, hostname),
|
|
356
|
-
);
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
#startHttpServer(
|
|
360
|
-
createServer: typeof import("node:http").createServer,
|
|
361
|
-
port: number,
|
|
362
|
-
hostname: string,
|
|
363
|
-
): Promise<Schmock.ServerInfo> {
|
|
364
|
-
const httpServer = createServer((req, res) => {
|
|
365
|
-
const handleRequest = async () => {
|
|
366
|
-
const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
|
|
367
|
-
const method = toHttpMethod(req.method ?? "GET");
|
|
368
|
-
const path = url.pathname;
|
|
369
|
-
const headers = parseNodeHeaders(req);
|
|
370
|
-
const query = parseNodeQuery(url);
|
|
371
|
-
const body = await collectBody(req, headers);
|
|
372
|
-
const schmockResponse = await this.handle(method, path, {
|
|
373
|
-
headers,
|
|
374
|
-
body,
|
|
375
|
-
query,
|
|
376
|
-
});
|
|
377
|
-
writeSchmockResponse(res, schmockResponse);
|
|
378
|
-
};
|
|
379
|
-
|
|
380
|
-
handleRequest().catch((error) => {
|
|
381
|
-
if (!res.headersSent) {
|
|
382
|
-
res.writeHead(500, { "content-type": "application/json" });
|
|
383
|
-
}
|
|
384
|
-
res.end(
|
|
385
|
-
JSON.stringify({
|
|
386
|
-
error:
|
|
387
|
-
error instanceof Error ? error.message : "Internal Server Error",
|
|
388
|
-
code: "SERVER_ERROR",
|
|
389
|
-
}),
|
|
390
|
-
);
|
|
391
|
-
});
|
|
392
|
-
});
|
|
393
|
-
|
|
394
|
-
this.server = httpServer;
|
|
395
|
-
|
|
396
|
-
return new Promise((resolve, reject) => {
|
|
397
|
-
httpServer.on("error", reject);
|
|
398
|
-
httpServer.listen(port, hostname, () => {
|
|
399
|
-
const addr = httpServer.address();
|
|
400
|
-
const actualPort =
|
|
401
|
-
addr !== null && typeof addr === "object" ? addr.port : port;
|
|
402
|
-
this.serverInfo = { port: actualPort, hostname };
|
|
403
|
-
this.logger.log("server", `Listening on ${hostname}:${actualPort}`);
|
|
404
|
-
resolve(this.serverInfo);
|
|
405
|
-
});
|
|
406
|
-
});
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
close(): void {
|
|
410
|
-
if (!this.server) {
|
|
411
|
-
return;
|
|
412
|
-
}
|
|
413
|
-
this.server.closeAllConnections();
|
|
414
|
-
this.server.close();
|
|
415
|
-
this.server = undefined;
|
|
416
|
-
this.serverInfo = undefined;
|
|
417
|
-
this.logger.log("server", "Server stopped");
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// ===== Fetch Interceptor =====
|
|
421
|
-
|
|
422
|
-
intercept(options?: Schmock.InterceptOptions): Schmock.InterceptHandle {
|
|
423
|
-
if (this.interceptHandle?.active) {
|
|
424
|
-
throw new SchmockError(
|
|
425
|
-
"Already intercepting. Call restore() first.",
|
|
426
|
-
"ALREADY_INTERCEPTING",
|
|
427
|
-
);
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
this.interceptHandle = createFetchInterceptor(
|
|
431
|
-
(method, path, opts) => this.handle(method, path, opts),
|
|
432
|
-
options,
|
|
433
|
-
);
|
|
434
|
-
|
|
435
|
-
return this.interceptHandle;
|
|
436
|
-
}
|
|
437
|
-
|
|
438
|
-
async handle(
|
|
439
|
-
method: Schmock.HttpMethod,
|
|
440
|
-
path: string,
|
|
441
|
-
options?: Schmock.RequestOptions,
|
|
442
|
-
): Promise<Schmock.Response> {
|
|
443
|
-
const handleStart = performance.now();
|
|
444
|
-
const requestId = this.globalConfig.debug ? crypto.randomUUID() : "";
|
|
445
|
-
const reqQuery = options?.query || {};
|
|
446
|
-
const reqHeaders = options?.headers || {};
|
|
447
|
-
this.logger.log("request", `[${requestId}] ${method} ${path}`, {
|
|
448
|
-
headers: reqHeaders,
|
|
449
|
-
query: reqQuery,
|
|
450
|
-
bodyType: options?.body ? typeof options.body : "none",
|
|
451
|
-
});
|
|
452
|
-
this.logger.time(`request-${requestId}`);
|
|
453
|
-
|
|
454
|
-
this.emit("request:start", {
|
|
455
|
-
method,
|
|
456
|
-
path,
|
|
457
|
-
headers: reqHeaders,
|
|
458
|
-
});
|
|
459
|
-
|
|
460
|
-
try {
|
|
461
|
-
// Apply namespace if configured
|
|
462
|
-
let requestPath = path;
|
|
463
|
-
if (this.globalConfig.namespace && this.globalConfig.namespace !== "/") {
|
|
464
|
-
const namespace = this.globalConfig.namespace.startsWith("/")
|
|
465
|
-
? this.globalConfig.namespace
|
|
466
|
-
: `/${this.globalConfig.namespace}`;
|
|
467
|
-
|
|
468
|
-
const pathToCheck = path.startsWith("/") ? path : `/${path}`;
|
|
469
|
-
|
|
470
|
-
// Check if path starts with namespace
|
|
471
|
-
// handle both "/api/users" (starts with /api) and "/api" (exact match)
|
|
472
|
-
// but NOT "/apiv2" (prefix match but wrong segment)
|
|
473
|
-
const isMatch =
|
|
474
|
-
pathToCheck === namespace ||
|
|
475
|
-
pathToCheck.startsWith(
|
|
476
|
-
namespace.endsWith("/") ? namespace : `${namespace}/`,
|
|
477
|
-
);
|
|
478
|
-
|
|
479
|
-
if (!isMatch) {
|
|
480
|
-
this.logger.log(
|
|
481
|
-
"route",
|
|
482
|
-
`[${requestId}] Path doesn't match namespace ${namespace}`,
|
|
483
|
-
);
|
|
484
|
-
const error = new RouteNotFoundError(method, path);
|
|
485
|
-
const response = {
|
|
486
|
-
status: 404,
|
|
487
|
-
body: { error: error.message, code: error.code },
|
|
488
|
-
headers: {},
|
|
489
|
-
};
|
|
490
|
-
this.emit("request:end", {
|
|
491
|
-
method,
|
|
492
|
-
path,
|
|
493
|
-
status: 404,
|
|
494
|
-
duration: performance.now() - handleStart,
|
|
495
|
-
});
|
|
496
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
497
|
-
return response;
|
|
498
|
-
}
|
|
499
|
-
|
|
500
|
-
// Remove namespace prefix, ensuring we always start with /
|
|
501
|
-
const stripped = pathToCheck.slice(namespace.length);
|
|
502
|
-
requestPath = stripped.startsWith("/") ? stripped : `/${stripped}`;
|
|
503
|
-
}
|
|
504
|
-
|
|
505
|
-
// Find matching route
|
|
506
|
-
const matchedRoute = findRoute(
|
|
507
|
-
method,
|
|
508
|
-
requestPath,
|
|
509
|
-
this.staticRoutes,
|
|
510
|
-
this.routes,
|
|
511
|
-
);
|
|
512
|
-
|
|
513
|
-
if (!matchedRoute) {
|
|
514
|
-
this.logger.log(
|
|
515
|
-
"route",
|
|
516
|
-
`[${requestId}] No route found for ${method} ${requestPath}`,
|
|
517
|
-
);
|
|
518
|
-
this.emit("request:notfound", { method, path: requestPath });
|
|
519
|
-
const error = new RouteNotFoundError(method, path);
|
|
520
|
-
const response = {
|
|
521
|
-
status: 404,
|
|
522
|
-
body: { error: error.message, code: error.code },
|
|
523
|
-
headers: {},
|
|
524
|
-
};
|
|
525
|
-
this.emit("request:end", {
|
|
526
|
-
method,
|
|
527
|
-
path: requestPath,
|
|
528
|
-
status: 404,
|
|
529
|
-
duration: performance.now() - handleStart,
|
|
530
|
-
});
|
|
531
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
532
|
-
return response;
|
|
533
|
-
}
|
|
534
|
-
|
|
535
|
-
this.logger.log(
|
|
536
|
-
"route",
|
|
537
|
-
`[${requestId}] Matched route: ${method} ${matchedRoute.path}`,
|
|
538
|
-
);
|
|
539
|
-
|
|
540
|
-
// Extract parameters from the matched route
|
|
541
|
-
const params = extractParams(matchedRoute, requestPath);
|
|
542
|
-
|
|
543
|
-
this.emit("request:match", {
|
|
544
|
-
method,
|
|
545
|
-
path: requestPath,
|
|
546
|
-
routePath: matchedRoute.path,
|
|
547
|
-
params,
|
|
548
|
-
});
|
|
549
|
-
|
|
550
|
-
// Generate initial response from route handler
|
|
551
|
-
const context: Schmock.RequestContext = {
|
|
552
|
-
method,
|
|
553
|
-
path: requestPath,
|
|
554
|
-
params,
|
|
555
|
-
query: reqQuery,
|
|
556
|
-
headers: reqHeaders,
|
|
557
|
-
body: options?.body,
|
|
558
|
-
state: this.globalConfig.state || {},
|
|
559
|
-
};
|
|
560
|
-
|
|
561
|
-
let result: unknown;
|
|
562
|
-
if (isGeneratorFunction(matchedRoute.generator)) {
|
|
563
|
-
result = await matchedRoute.generator(context);
|
|
564
|
-
} else {
|
|
565
|
-
result = matchedRoute.generator;
|
|
566
|
-
}
|
|
567
|
-
|
|
568
|
-
// Build plugin context
|
|
569
|
-
let pluginContext: Schmock.PluginContext = {
|
|
570
|
-
path: requestPath,
|
|
571
|
-
route: matchedRoute.config,
|
|
572
|
-
method,
|
|
573
|
-
params,
|
|
574
|
-
query: reqQuery,
|
|
575
|
-
headers: reqHeaders,
|
|
576
|
-
body: options?.body,
|
|
577
|
-
state: new Map(),
|
|
578
|
-
routeState: this.globalConfig.state || {},
|
|
579
|
-
};
|
|
580
|
-
|
|
581
|
-
// Run plugin pipeline to transform the response
|
|
582
|
-
try {
|
|
583
|
-
const pipelineResult = await runPluginPipeline(
|
|
584
|
-
this.plugins,
|
|
585
|
-
pluginContext,
|
|
586
|
-
result,
|
|
587
|
-
this.logger,
|
|
588
|
-
);
|
|
589
|
-
pluginContext = pipelineResult.context;
|
|
590
|
-
result = pipelineResult.response;
|
|
591
|
-
} catch (error) {
|
|
592
|
-
this.logger.log(
|
|
593
|
-
"error",
|
|
594
|
-
`[${requestId}] Plugin pipeline error: ${errorMessage(error)}`,
|
|
595
|
-
);
|
|
596
|
-
throw error;
|
|
597
|
-
}
|
|
598
|
-
|
|
599
|
-
// Parse and prepare response
|
|
600
|
-
const response = parseResponse(result, matchedRoute.config);
|
|
601
|
-
|
|
602
|
-
// Apply delay (route-level overrides global)
|
|
603
|
-
await this.applyDelay(matchedRoute.config.delay);
|
|
604
|
-
|
|
605
|
-
// Record request in history (FIFO-bounded when maxHistorySize is set)
|
|
606
|
-
this.requestHistory.push({
|
|
607
|
-
method,
|
|
608
|
-
path: requestPath,
|
|
609
|
-
params,
|
|
610
|
-
query: reqQuery,
|
|
611
|
-
headers: reqHeaders,
|
|
612
|
-
body: options?.body,
|
|
613
|
-
timestamp: Date.now(),
|
|
614
|
-
response: { status: response.status, body: response.body },
|
|
615
|
-
});
|
|
616
|
-
const maxHistorySize = this.globalConfig.maxHistorySize;
|
|
617
|
-
if (
|
|
618
|
-
typeof maxHistorySize === "number" &&
|
|
619
|
-
maxHistorySize >= 0 &&
|
|
620
|
-
this.requestHistory.length > maxHistorySize
|
|
621
|
-
) {
|
|
622
|
-
this.requestHistory.splice(
|
|
623
|
-
0,
|
|
624
|
-
this.requestHistory.length - maxHistorySize,
|
|
625
|
-
);
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
this.emit("request:end", {
|
|
629
|
-
method,
|
|
630
|
-
path: requestPath,
|
|
631
|
-
status: response.status,
|
|
632
|
-
duration: performance.now() - handleStart,
|
|
633
|
-
});
|
|
634
|
-
|
|
635
|
-
// Log successful response
|
|
636
|
-
this.logger.log(
|
|
637
|
-
"response",
|
|
638
|
-
`[${requestId}] Sending response ${response.status}`,
|
|
639
|
-
{
|
|
640
|
-
status: response.status,
|
|
641
|
-
headers: response.headers,
|
|
642
|
-
bodyType: typeof response.body,
|
|
643
|
-
},
|
|
644
|
-
);
|
|
645
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
646
|
-
|
|
647
|
-
return response;
|
|
648
|
-
} catch (error) {
|
|
649
|
-
this.logger.log(
|
|
650
|
-
"error",
|
|
651
|
-
`[${requestId}] Error processing request: ${errorMessage(error)}`,
|
|
652
|
-
error,
|
|
653
|
-
);
|
|
654
|
-
|
|
655
|
-
// Return error response
|
|
656
|
-
const errorResponse = {
|
|
657
|
-
status: 500,
|
|
658
|
-
body: {
|
|
659
|
-
error: errorMessage(error),
|
|
660
|
-
code: error instanceof SchmockError ? error.code : "INTERNAL_ERROR",
|
|
661
|
-
},
|
|
662
|
-
headers: {},
|
|
663
|
-
};
|
|
664
|
-
|
|
665
|
-
// Apply delay even for error responses
|
|
666
|
-
await this.applyDelay();
|
|
667
|
-
|
|
668
|
-
this.emit("request:end", {
|
|
669
|
-
method,
|
|
670
|
-
path,
|
|
671
|
-
status: 500,
|
|
672
|
-
duration: performance.now() - handleStart,
|
|
673
|
-
});
|
|
674
|
-
|
|
675
|
-
this.logger.log("error", `[${requestId}] Returning error response 500`);
|
|
676
|
-
this.logger.timeEnd(`request-${requestId}`);
|
|
677
|
-
return errorResponse;
|
|
678
|
-
}
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
/**
|
|
682
|
-
* Apply configured response delay
|
|
683
|
-
* Supports both fixed delays and random delays within a range
|
|
684
|
-
* @private
|
|
685
|
-
*/
|
|
686
|
-
private async applyDelay(
|
|
687
|
-
routeDelay?: number | [number, number],
|
|
688
|
-
): Promise<void> {
|
|
689
|
-
const effectiveDelay = routeDelay ?? this.globalConfig.delay;
|
|
690
|
-
if (!effectiveDelay) {
|
|
691
|
-
return;
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
const ms = Array.isArray(effectiveDelay)
|
|
695
|
-
? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
|
|
696
|
-
effectiveDelay[0]
|
|
697
|
-
: effectiveDelay;
|
|
698
|
-
|
|
699
|
-
await new Promise((resolve) => setTimeout(resolve, ms));
|
|
700
|
-
}
|
|
701
|
-
}
|