@schmock/core 2.4.1 → 2.5.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.
Files changed (66) hide show
  1. package/README.md +129 -0
  2. package/dist/abort.d.ts +11 -1
  3. package/dist/abort.js +13 -2
  4. package/dist/adapter.d.ts +19 -0
  5. package/dist/adapter.js +17 -0
  6. package/dist/admission.d.ts +21 -0
  7. package/dist/admission.js +39 -0
  8. package/dist/binary.d.ts +0 -1
  9. package/dist/builder.d.ts +16 -32
  10. package/dist/builder.js +397 -903
  11. package/dist/constants.d.ts +33 -2
  12. package/dist/constants.js +73 -1
  13. package/dist/debug-logger.d.ts +10 -0
  14. package/dist/debug-logger.js +31 -0
  15. package/dist/delay.d.ts +12 -0
  16. package/dist/delay.js +37 -0
  17. package/dist/errors.d.ts +13 -2
  18. package/dist/errors.js +21 -2
  19. package/dist/events.d.ts +17 -0
  20. package/dist/events.js +58 -0
  21. package/dist/generations.d.ts +43 -0
  22. package/dist/generations.js +75 -0
  23. package/dist/headers.d.ts +27 -0
  24. package/dist/headers.js +57 -0
  25. package/dist/helpers.d.ts +9 -10
  26. package/dist/helpers.js +4 -1
  27. package/dist/history.d.ts +56 -0
  28. package/dist/history.js +230 -0
  29. package/dist/http-helpers.d.ts +110 -5
  30. package/dist/http-helpers.js +328 -46
  31. package/dist/index.d.ts +213 -31
  32. package/dist/index.js +17 -9
  33. package/dist/interceptor.d.ts +15 -11
  34. package/dist/interceptor.js +241 -164
  35. package/dist/node-server.d.ts +27 -0
  36. package/dist/node-server.js +166 -0
  37. package/dist/parser.d.ts +0 -1
  38. package/dist/parser.js +145 -22
  39. package/dist/plugin-hooks.d.ts +40 -0
  40. package/dist/plugin-hooks.js +192 -0
  41. package/dist/plugin-pipeline.d.ts +0 -1
  42. package/dist/plugin-pipeline.js +25 -4
  43. package/dist/response-normalizer.d.ts +36 -1
  44. package/dist/response-normalizer.js +102 -0
  45. package/dist/response-parser.d.ts +19 -1
  46. package/dist/response-parser.js +77 -19
  47. package/dist/route-matcher.d.ts +0 -1
  48. package/dist/route-table.d.ts +64 -0
  49. package/dist/route-table.js +220 -0
  50. package/dist/types.d.ts +27 -1
  51. package/package.json +8 -3
  52. package/dist/abort.d.ts.map +0 -1
  53. package/dist/binary.d.ts.map +0 -1
  54. package/dist/builder.d.ts.map +0 -1
  55. package/dist/constants.d.ts.map +0 -1
  56. package/dist/errors.d.ts.map +0 -1
  57. package/dist/helpers.d.ts.map +0 -1
  58. package/dist/http-helpers.d.ts.map +0 -1
  59. package/dist/index.d.ts.map +0 -1
  60. package/dist/interceptor.d.ts.map +0 -1
  61. package/dist/parser.d.ts.map +0 -1
  62. package/dist/plugin-pipeline.d.ts.map +0 -1
  63. package/dist/response-normalizer.d.ts.map +0 -1
  64. package/dist/response-parser.d.ts.map +0 -1
  65. package/dist/route-matcher.d.ts.map +0 -1
  66. package/dist/types.d.ts.map +0 -1
@@ -12,5 +12,40 @@ export declare function normalizeResponse(response: NormalizableResponse, method
12
12
  * Encode a response body using its normalized content type semantics.
13
13
  */
14
14
  export declare function serializeResponseBody(response: Schmock.Response): OwnedBytes | undefined;
15
+ /**
16
+ * Give a response the content type its body implies when it declares none:
17
+ * `application/octet-stream` for a binary body, `application/json` for any
18
+ * other non-string body (`null` included, which serializes as JSON). A string
19
+ * body is sent as-is and gets no default.
20
+ *
21
+ * Total: it never throws and never mutates `response`. The result is not
22
+ * normalized; pass it to `normalizeResponse` when it still needs to be.
23
+ */
24
+ export declare function withDefaultContentType(response: Schmock.Response): Schmock.Response;
25
+ /**
26
+ * The JSON error envelope `{ "error": message, "code": code }` every transport
27
+ * answers a failure with, normalized for `method`. `headers` are added after
28
+ * the JSON content type (a 405's `allow`). The one constructor of that shape,
29
+ * so `handle()`, `listen()` and `intercept()` cannot drift apart.
30
+ */
31
+ export declare function buildJsonErrorResponse(input: {
32
+ status: number;
33
+ error: string;
34
+ code: string;
35
+ method: string;
36
+ headers?: Readonly<Record<string, string>>;
37
+ }): Schmock.Response;
38
+ /**
39
+ * Run an `errorFormatter` and build the normalized 500 that carries its result.
40
+ *
41
+ * Total: it never throws, and the formatter runs exactly once. There are two
42
+ * fallbacks. When the inherited headers cannot be sent (a non-string value, a
43
+ * control character, a case-duplicate name), the formatted body is kept and
44
+ * sent with the fixed JSON header set instead, since losing the body would
45
+ * silently change the caller's error contract. When the formatter throws or
46
+ * its result cannot be serialized, the minimal
47
+ * `{ error: "Internal Server Error", code: "INTERNAL_ERROR" }` body is sent,
48
+ * inheriting nothing.
49
+ */
50
+ export declare function buildFormattedErrorResponse(options: Schmock.FormattedErrorOptions): Schmock.Response;
15
51
  export {};
16
- //# sourceMappingURL=response-normalizer.d.ts.map
@@ -1,5 +1,6 @@
1
1
  import { isBinaryBody } from "./binary.js";
2
2
  import { errorMessage, InvalidResponseError } from "./errors.js";
3
+ import { hasHeader } from "./headers.js";
3
4
  const BODY_FORBIDDEN_STATUSES = new Set([204, 205, 304]);
4
5
  const HEADER_NAME_PATTERN = /^[!#$%&'*+\-.^_`|~0-9A-Za-z]+$/;
5
6
  const FRAMING_HEADERS = new Set([
@@ -314,3 +315,104 @@ export function serializeResponseBody(response) {
314
315
  const serialized = typeof body === "string" ? body : stringifyJsonBody(body);
315
316
  return new TextEncoder().encode(serialized);
316
317
  }
318
+ /**
319
+ * Give a response the content type its body implies when it declares none:
320
+ * `application/octet-stream` for a binary body, `application/json` for any
321
+ * other non-string body (`null` included, which serializes as JSON). A string
322
+ * body is sent as-is and gets no default.
323
+ *
324
+ * Total: it never throws and never mutates `response`. The result is not
325
+ * normalized; pass it to `normalizeResponse` when it still needs to be.
326
+ */
327
+ export function withDefaultContentType(response) {
328
+ const headers = { ...response.headers };
329
+ const body = response.body;
330
+ if (body !== undefined && !hasHeader(headers, "content-type")) {
331
+ if (isBinaryBody(body)) {
332
+ headers["content-type"] = "application/octet-stream";
333
+ }
334
+ else if (typeof body !== "string") {
335
+ headers["content-type"] = "application/json";
336
+ }
337
+ }
338
+ return { status: response.status, body, headers };
339
+ }
340
+ /**
341
+ * A formatted error body is always JSON, whatever the replaced response
342
+ * declared. Every case variant of content-type is dropped first: a leftover
343
+ * `Content-Type` beside the lowercase key makes the pair untransportable.
344
+ */
345
+ function withJsonContentType(headers) {
346
+ const result = {};
347
+ for (const [name, value] of Object.entries(headers ?? {})) {
348
+ if (name.toLowerCase() === "content-type")
349
+ continue;
350
+ result[name] = value;
351
+ }
352
+ result["content-type"] = "application/json";
353
+ return result;
354
+ }
355
+ /**
356
+ * The JSON error envelope `{ "error": message, "code": code }` every transport
357
+ * answers a failure with, normalized for `method`. `headers` are added after
358
+ * the JSON content type (a 405's `allow`). The one constructor of that shape,
359
+ * so `handle()`, `listen()` and `intercept()` cannot drift apart.
360
+ */
361
+ export function buildJsonErrorResponse(input) {
362
+ return normalizeResponse({
363
+ status: input.status,
364
+ body: { error: input.error, code: input.code },
365
+ headers: { "content-type": "application/json", ...input.headers },
366
+ }, input.method);
367
+ }
368
+ function internalErrorResponse(method) {
369
+ return buildJsonErrorResponse({
370
+ status: 500,
371
+ error: "Internal Server Error",
372
+ code: "INTERNAL_ERROR",
373
+ method,
374
+ });
375
+ }
376
+ /**
377
+ * Run an `errorFormatter` and build the normalized 500 that carries its result.
378
+ *
379
+ * Total: it never throws, and the formatter runs exactly once. There are two
380
+ * fallbacks. When the inherited headers cannot be sent (a non-string value, a
381
+ * control character, a case-duplicate name), the formatted body is kept and
382
+ * sent with the fixed JSON header set instead, since losing the body would
383
+ * silently change the caller's error contract. When the formatter throws or
384
+ * its result cannot be serialized, the minimal
385
+ * `{ error: "Internal Server Error", code: "INTERNAL_ERROR" }` body is sent,
386
+ * inheriting nothing.
387
+ */
388
+ export function buildFormattedErrorResponse(options) {
389
+ const { formatter, error, inheritedHeaders, method } = options;
390
+ let formatted;
391
+ try {
392
+ formatted = formatter(error);
393
+ }
394
+ catch {
395
+ return internalErrorResponse(method);
396
+ }
397
+ try {
398
+ return normalizeResponse({
399
+ status: 500,
400
+ body: formatted,
401
+ headers: withJsonContentType(inheritedHeaders),
402
+ }, method);
403
+ }
404
+ catch {
405
+ // The inherited headers were not transportable. `formatted` is reused,
406
+ // so the formatter still fires exactly once.
407
+ }
408
+ try {
409
+ return normalizeResponse({
410
+ status: 500,
411
+ body: formatted,
412
+ headers: { "content-type": "application/json" },
413
+ }, method);
414
+ }
415
+ catch {
416
+ return internalErrorResponse(method);
417
+ }
418
+ }
@@ -1,6 +1,24 @@
1
+ /**
2
+ * Split a route or plugin result into the status, body and headers core will
3
+ * answer with, using exactly the guards `handle()` applies: an object is an
4
+ * envelope only when it has a numeric `status`, a `body`, and `headers` that
5
+ * are absent or a string record; anything else is delivered whole as the body.
6
+ *
7
+ * `body` is the element as carried (`null` stays `null`, though core sends no
8
+ * body for it), `status` is what core answers with (a plain `null` or
9
+ * `undefined` result is 204), and `headers` is a fresh copy, `{}` when the
10
+ * carried headers are not a string record.
11
+ */
12
+ export declare function getResponseParts(response: unknown): Schmock.ResponseParts;
13
+ /**
14
+ * Put `body` in place of the body `response` carries, keeping its shape: a
15
+ * tuple stays a tuple of the same length, an envelope keeps its status and
16
+ * headers (other properties are dropped, as core ignores them), and a plain
17
+ * result is replaced by `body` itself. Never mutates `response`.
18
+ */
19
+ export declare function replaceResponseBody(response: unknown, body: unknown): unknown;
1
20
  /**
2
21
  * Parse and normalize response result into Response object
3
22
  * Handles tuple format [status, body, headers], direct values, and response objects
4
23
  */
5
24
  export declare function parseResponse(result: unknown, routeConfig: Schmock.RouteConfig): Schmock.Response;
6
- //# sourceMappingURL=response-parser.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { isBinaryBody } from "./binary.js";
2
2
  import { isStatusTuple } from "./constants.js";
3
3
  import { InvalidResponseError } from "./errors.js";
4
+ import { hasHeader } from "./headers.js";
4
5
  const BINARY_CONTENT_TYPE = "application/octet-stream";
5
6
  /**
6
7
  * Take ownership of caller-supplied response headers.
@@ -25,7 +26,7 @@ function toOwnHeaderRecord(value) {
25
26
  return record;
26
27
  }
27
28
  function hasContentType(headers) {
28
- return Object.keys(headers).some((header) => header.toLowerCase() === "content-type");
29
+ return hasHeader(headers, "content-type");
29
30
  }
30
31
  /**
31
32
  * Detect the object response envelope `{ status, body, headers? }`.
@@ -35,8 +36,9 @@ function hasContentType(headers) {
35
36
  * payload. Callers who need to return such a shape as data should nest it or
36
37
  * use an explicit `[status, body]` tuple for the envelope. An object whose
37
38
  * `headers` is present but not a string record is deliberately NOT an envelope
38
- * and is delivered whole — plugins that inspect responses must use this same
39
- * rule (see `@schmock/validation`) or they will judge an undelivered payload.
39
+ * and is delivered whole — plugins that inspect responses read them through
40
+ * {@link getResponseParts}, which applies this same rule, or they will judge
41
+ * an undelivered payload.
40
42
  */
41
43
  function isResponseObject(value) {
42
44
  return (typeof value === "object" &&
@@ -56,27 +58,83 @@ function isStringRecord(value) {
56
58
  Object.values(value).every((entry) => typeof entry === "string"));
57
59
  }
58
60
  /**
59
- * Parse and normalize response result into Response object
60
- * Handles tuple format [status, body, headers], direct values, and response objects
61
+ * The single place a route result is split into status, body and headers.
62
+ * `parseResponse` and the exported `getResponseParts` both build on it, so
63
+ * what a plugin inspects is what core delivers.
61
64
  */
62
- export function parseResponse(result, routeConfig) {
63
- let status = 200;
64
- let body = result;
65
- let headers = {};
66
- let tupleFormat = false;
65
+ function decomposeResponse(result) {
67
66
  // Handle already-formed response objects (from plugin error recovery)
68
67
  if (isResponseObject(result)) {
69
- status = result.status;
70
- body = result.body;
71
- headers = toOwnHeaderRecord(result.headers);
72
- tupleFormat = true;
68
+ return {
69
+ kind: "object",
70
+ status: result.status,
71
+ body: result.body,
72
+ rawHeaders: result.headers,
73
+ };
73
74
  }
74
- else if (isStatusTuple(result)) {
75
- // Handle tuple response format [status, body, headers?]
76
- [status, body] = result;
77
- headers = toOwnHeaderRecord(result[2]);
78
- tupleFormat = true;
75
+ // Handle tuple response format [status, body, headers?]
76
+ if (isStatusTuple(result)) {
77
+ return {
78
+ kind: "tuple",
79
+ status: result[0],
80
+ body: result[1],
81
+ rawHeaders: result[2],
82
+ };
79
83
  }
84
+ return { kind: "plain", status: 200, body: result, rawHeaders: undefined };
85
+ }
86
+ function isNullish(value) {
87
+ return value === null || value === undefined;
88
+ }
89
+ /**
90
+ * Split a route or plugin result into the status, body and headers core will
91
+ * answer with, using exactly the guards `handle()` applies: an object is an
92
+ * envelope only when it has a numeric `status`, a `body`, and `headers` that
93
+ * are absent or a string record; anything else is delivered whole as the body.
94
+ *
95
+ * `body` is the element as carried (`null` stays `null`, though core sends no
96
+ * body for it), `status` is what core answers with (a plain `null` or
97
+ * `undefined` result is 204), and `headers` is a fresh copy, `{}` when the
98
+ * carried headers are not a string record.
99
+ */
100
+ export function getResponseParts(response) {
101
+ const parts = decomposeResponse(response);
102
+ return {
103
+ kind: parts.kind,
104
+ status: parts.kind === "plain" && isNullish(parts.body) ? 204 : parts.status,
105
+ body: parts.body,
106
+ headers: isStringRecord(parts.rawHeaders) ? { ...parts.rawHeaders } : {},
107
+ };
108
+ }
109
+ /**
110
+ * Put `body` in place of the body `response` carries, keeping its shape: a
111
+ * tuple stays a tuple of the same length, an envelope keeps its status and
112
+ * headers (other properties are dropped, as core ignores them), and a plain
113
+ * result is replaced by `body` itself. Never mutates `response`.
114
+ */
115
+ export function replaceResponseBody(response, body) {
116
+ if (isResponseObject(response)) {
117
+ return response.headers === undefined
118
+ ? { status: response.status, body }
119
+ : { status: response.status, body, headers: response.headers };
120
+ }
121
+ if (isStatusTuple(response)) {
122
+ return response.length === 3
123
+ ? [response[0], body, response[2]]
124
+ : [response[0], body];
125
+ }
126
+ return body;
127
+ }
128
+ /**
129
+ * Parse and normalize response result into Response object
130
+ * Handles tuple format [status, body, headers], direct values, and response objects
131
+ */
132
+ export function parseResponse(result, routeConfig) {
133
+ const parts = decomposeResponse(result);
134
+ let status = parts.status;
135
+ let body = parts.body;
136
+ const headers = toOwnHeaderRecord(parts.rawHeaders);
137
+ const tupleFormat = parts.kind !== "plain";
80
138
  // Handle null/undefined responses with 204 No Content
81
139
  // But don't auto-convert if tuple format was used (status was explicitly provided)
82
140
  if (body === null || body === undefined) {
@@ -24,4 +24,3 @@ export declare function findRoute(method: Schmock.HttpMethod, path: string, stat
24
24
  * captures are decoded afterwards, so a generator receives readable values.
25
25
  */
26
26
  export declare function extractParams(route: CompiledCallableRoute, path: string): Record<string, string>;
27
- //# sourceMappingURL=route-matcher.d.ts.map
@@ -0,0 +1,64 @@
1
+ import type { DebugLogger } from "./debug-logger.js";
2
+ import type { CompiledCallableRoute } from "./route-matcher.js";
3
+ /** The route containers an admitted request routes with. */
4
+ export interface RouteTableSnapshot {
5
+ readonly routes: CompiledCallableRoute[];
6
+ readonly staticRoutes: Map<string, CompiledCallableRoute>;
7
+ }
8
+ /** The table as it stood before a `pipe()` install, for its rollback. */
9
+ interface RouteTableCheckpoint extends RouteTableSnapshot {
10
+ readonly shared: boolean;
11
+ }
12
+ /**
13
+ * The mock's registered routes: every route in registration order, plus the
14
+ * static (parameterless) ones in a map for O(1) lookup.
15
+ *
16
+ * The containers are copy-on-write. An admitted request captures them by
17
+ * reference (`share()`), and the first registration after that copies them,
18
+ * so an in-flight snapshot never changes underneath its request and no
19
+ * request pays for a copy of the whole table.
20
+ */
21
+ export declare class RouteTable {
22
+ #private;
23
+ /** O(1) snapshot for an admitted request. */
24
+ share(): RouteTableSnapshot;
25
+ define(input: {
26
+ route: Schmock.RouteKey;
27
+ generator: Schmock.Generator;
28
+ config: Schmock.RouteConfig;
29
+ logger: DebugLogger;
30
+ }): void;
31
+ list(): Schmock.RouteInfo[];
32
+ /**
33
+ * Start a transaction: later registrations go into fresh copies, and
34
+ * `rollback()` puts the table back exactly as it was.
35
+ */
36
+ checkpoint(): RouteTableCheckpoint;
37
+ rollback(checkpoint: RouteTableCheckpoint): void;
38
+ /**
39
+ * Empty the table. The containers are replaced, never cleared in place:
40
+ * in-flight admissions still route with the old ones.
41
+ */
42
+ clear(): void;
43
+ }
44
+ /**
45
+ * A per-request copy of a route's config for the plugin context.
46
+ *
47
+ * Shallow on purpose: plugin metadata under `openapi:*` keys is shared,
48
+ * read-only structure (and some of it is keyed by identity in WeakMaps), but a
49
+ * plugin that assigns `context.route.contentType` or edits the delay tuple must
50
+ * not change every later request.
51
+ */
52
+ export declare function copyRouteConfig(config: Schmock.RouteConfig): Schmock.RouteConfig;
53
+ /**
54
+ * Deep-copy the plain data (arrays and plain objects) of a static generator so
55
+ * plugins can edit their response in place without changing the route.
56
+ *
57
+ * Anything else — dates, binary values, class instances with a prototype
58
+ * `toJSON` — is passed by reference: `structuredClone` would strip those
59
+ * prototypes (a Buffer would come back as a bare Uint8Array) and change what
60
+ * the response serializes to. Enumerable symbol keys are kept so the response
61
+ * normalizer still sees, and rejects, them.
62
+ */
63
+ export declare function copyStaticData(value: unknown, copies?: Map<object, unknown>): unknown;
64
+ export {};
@@ -0,0 +1,220 @@
1
+ import { isBinaryBody } from "./binary.js";
2
+ import { normalizePath } from "./constants.js";
3
+ import { RouteDefinitionError } from "./errors.js";
4
+ import { parseRouteKey } from "./parser.js";
5
+ function defaultContentType(generator) {
6
+ if (typeof generator === "function") {
7
+ // Default to JSON for function generators
8
+ return "application/json";
9
+ }
10
+ if (typeof generator === "string" ||
11
+ typeof generator === "number" ||
12
+ typeof generator === "boolean") {
13
+ // Default to plain text for primitives
14
+ return "text/plain";
15
+ }
16
+ if (isBinaryBody(generator)) {
17
+ // Default to octet-stream for browser and Node binary values
18
+ return "application/octet-stream";
19
+ }
20
+ // Default to JSON for objects/arrays
21
+ return "application/json";
22
+ }
23
+ /**
24
+ * The mock's registered routes: every route in registration order, plus the
25
+ * static (parameterless) ones in a map for O(1) lookup.
26
+ *
27
+ * The containers are copy-on-write. An admitted request captures them by
28
+ * reference (`share()`), and the first registration after that copies them,
29
+ * so an in-flight snapshot never changes underneath its request and no
30
+ * request pays for a copy of the whole table.
31
+ */
32
+ export class RouteTable {
33
+ #routes = [];
34
+ #staticRoutes = new Map();
35
+ /** True once an admission holds the containers by reference. */
36
+ #shared = false;
37
+ /** O(1) snapshot for an admitted request. */
38
+ share() {
39
+ this.#shared = true;
40
+ return { routes: this.#routes, staticRoutes: this.#staticRoutes };
41
+ }
42
+ define(input) {
43
+ const { route, generator, logger } = input;
44
+ // FIX 1.2: shallow-clone the caller's config so mutations below stay private
45
+ const routeConfig = { ...input.config };
46
+ // Auto-detect contentType if not provided
47
+ if (!routeConfig.contentType) {
48
+ routeConfig.contentType = defaultContentType(generator);
49
+ }
50
+ // Validate generator matches contentType if it's static data
51
+ if (typeof generator !== "function" &&
52
+ routeConfig.contentType === "application/json") {
53
+ try {
54
+ JSON.stringify(generator);
55
+ }
56
+ catch (_error) {
57
+ throw new RouteDefinitionError(route, "Generator data is not valid JSON but contentType is application/json");
58
+ }
59
+ }
60
+ // Parse the route key to create pattern and extract parameters
61
+ const parsed = parseRouteKey(route);
62
+ // FIX 2.2: normalize paths before duplicate check so /users and /users/ are
63
+ // treated as the same route (consistent with the static-route Map key below).
64
+ // Routes that differ only in parameter names (`/users/:id` and
65
+ // `/users/:userId`) compile to the same pattern and match the same
66
+ // requests, so the later one would be unreachable: it is a duplicate too.
67
+ const normalizedParsedPath = normalizePath(parsed.path);
68
+ const existing = this.#routes.find((r) => r.method === parsed.method &&
69
+ (normalizePath(r.path) === normalizedParsedPath ||
70
+ r.pattern.source === parsed.pattern.source));
71
+ if (existing) {
72
+ logger.log("warning", normalizePath(existing.path) === normalizedParsedPath
73
+ ? `Duplicate route: ${route} — first registration wins`
74
+ : `Duplicate route: ${route} matches the same requests as ${existing.method} ${existing.path} — first registration wins`);
75
+ return;
76
+ }
77
+ // Compile the route
78
+ const compiledRoute = {
79
+ pattern: parsed.pattern,
80
+ params: parsed.params,
81
+ method: parsed.method,
82
+ path: parsed.path,
83
+ generator,
84
+ config: routeConfig,
85
+ };
86
+ this.#writable();
87
+ this.#routes.push(compiledRoute);
88
+ // Store static routes (no params) in Map for O(1) lookup
89
+ if (parsed.params.length === 0) {
90
+ const key = `${parsed.method} ${normalizePath(parsed.path)}`;
91
+ this.#staticRoutes.set(key, compiledRoute);
92
+ }
93
+ logger.log("route", `Route defined: ${route}`, {
94
+ contentType: routeConfig.contentType,
95
+ generatorType: typeof generator,
96
+ hasParams: parsed.params.length > 0,
97
+ });
98
+ }
99
+ list() {
100
+ return this.#routes.map((r) => ({
101
+ method: r.method,
102
+ path: r.path,
103
+ hasParams: r.params.length > 0,
104
+ }));
105
+ }
106
+ /**
107
+ * Start a transaction: later registrations go into fresh copies, and
108
+ * `rollback()` puts the table back exactly as it was.
109
+ */
110
+ checkpoint() {
111
+ const checkpoint = {
112
+ routes: this.#routes,
113
+ staticRoutes: this.#staticRoutes,
114
+ shared: this.#shared,
115
+ };
116
+ this.#routes = checkpoint.routes.slice();
117
+ this.#staticRoutes = new Map(checkpoint.staticRoutes);
118
+ this.#shared = false;
119
+ return checkpoint;
120
+ }
121
+ rollback(checkpoint) {
122
+ this.#routes = checkpoint.routes;
123
+ this.#staticRoutes = checkpoint.staticRoutes;
124
+ this.#shared = checkpoint.shared;
125
+ }
126
+ /**
127
+ * Empty the table. The containers are replaced, never cleared in place:
128
+ * in-flight admissions still route with the old ones.
129
+ */
130
+ clear() {
131
+ this.#routes = [];
132
+ this.#staticRoutes = new Map();
133
+ this.#shared = false;
134
+ }
135
+ /**
136
+ * Copy-on-write for the route tables. An admitted request routes with the
137
+ * containers it captured, by reference; the first registration after that
138
+ * copies them so the in-flight snapshot never changes underneath it.
139
+ */
140
+ #writable() {
141
+ if (!this.#shared)
142
+ return;
143
+ this.#routes = this.#routes.slice();
144
+ this.#staticRoutes = new Map(this.#staticRoutes);
145
+ this.#shared = false;
146
+ }
147
+ }
148
+ /**
149
+ * A per-request copy of a route's config for the plugin context.
150
+ *
151
+ * Shallow on purpose: plugin metadata under `openapi:*` keys is shared,
152
+ * read-only structure (and some of it is keyed by identity in WeakMaps), but a
153
+ * plugin that assigns `context.route.contentType` or edits the delay tuple must
154
+ * not change every later request.
155
+ */
156
+ export function copyRouteConfig(config) {
157
+ const copy = { ...config };
158
+ if (Array.isArray(config.delay)) {
159
+ copy.delay = [config.delay[0], config.delay[1]];
160
+ }
161
+ return copy;
162
+ }
163
+ function isPlainObject(value) {
164
+ const prototype = Object.getPrototypeOf(value);
165
+ return prototype === Object.prototype || prototype === null;
166
+ }
167
+ /**
168
+ * Define an own enumerable data property. Plain assignment would run the
169
+ * `__proto__` setter for an own `__proto__` key (which `JSON.parse` creates),
170
+ * dropping the key and re-prototyping the copy.
171
+ */
172
+ function defineDataProperty(target, key, value) {
173
+ Object.defineProperty(target, key, {
174
+ value,
175
+ enumerable: true,
176
+ writable: true,
177
+ configurable: true,
178
+ });
179
+ }
180
+ /**
181
+ * Deep-copy the plain data (arrays and plain objects) of a static generator so
182
+ * plugins can edit their response in place without changing the route.
183
+ *
184
+ * Anything else — dates, binary values, class instances with a prototype
185
+ * `toJSON` — is passed by reference: `structuredClone` would strip those
186
+ * prototypes (a Buffer would come back as a bare Uint8Array) and change what
187
+ * the response serializes to. Enumerable symbol keys are kept so the response
188
+ * normalizer still sees, and rejects, them.
189
+ */
190
+ export function copyStaticData(value, copies = new Map()) {
191
+ if (typeof value !== "object" || value === null)
192
+ return value;
193
+ const existing = copies.get(value);
194
+ if (existing !== undefined)
195
+ return existing;
196
+ if (Array.isArray(value)) {
197
+ const copy = new Array(value.length);
198
+ copies.set(value, copy);
199
+ for (let index = 0; index < value.length; index += 1) {
200
+ if (index in value)
201
+ copy[index] = copyStaticData(value[index], copies);
202
+ }
203
+ for (const key of Object.getOwnPropertySymbols(value)) {
204
+ if (!Object.getOwnPropertyDescriptor(value, key)?.enumerable)
205
+ continue;
206
+ defineDataProperty(copy, key, copyStaticData(Reflect.get(value, key), copies));
207
+ }
208
+ return copy;
209
+ }
210
+ if (!isPlainObject(value))
211
+ return value;
212
+ const copy = Object.getPrototypeOf(value) === null ? Object.create(null) : {};
213
+ copies.set(value, copy);
214
+ for (const key of Reflect.ownKeys(value)) {
215
+ if (!Object.getOwnPropertyDescriptor(value, key)?.enumerable)
216
+ continue;
217
+ defineDataProperty(copy, key, copyStaticData(Reflect.get(value, key), copies));
218
+ }
219
+ return copy;
220
+ }
package/dist/types.d.ts CHANGED
@@ -15,6 +15,7 @@ export type CallableMockInstance = Schmock.CallableMockInstance;
15
15
  export type Plugin = Schmock.Plugin;
16
16
  export type PluginContext = Schmock.PluginContext;
17
17
  export type PluginResult = Schmock.PluginResult;
18
+ export type PluginHookResult = Schmock.PluginHookResult;
18
19
  export type StaticData = Schmock.StaticData;
19
20
  export type RequestRecord = Schmock.RequestRecord;
20
21
  export type ServerInfo = Schmock.ServerInfo;
@@ -24,9 +25,22 @@ export type ResponseHeaderDef = Schmock.ResponseHeaderDef;
24
25
  export type CrudOperationMeta = Schmock.CrudOperationMeta;
25
26
  export type SchemaGenerationContext = Schmock.SchemaGenerationContext;
26
27
  export type FakerPluginOptions = Schmock.FakerPluginOptions;
28
+ /**
29
+ * @deprecated Import `ExpressAdapterOptions` from `@schmock/express`, which
30
+ * types `req`/`res` as Express's `Request`/`Response`. This copy types them
31
+ * `unknown` and will be removed in the next major version.
32
+ */
27
33
  export type ExpressAdapterOptions = Schmock.ExpressAdapterOptions;
34
+ /**
35
+ * @deprecated Import `AngularAdapterOptions` from `@schmock/angular`, which
36
+ * types `request` as Angular's `HttpRequest`. This copy types it `unknown` and
37
+ * will be removed in the next major version.
38
+ */
28
39
  export type AngularAdapterOptions = Schmock.AngularAdapterOptions;
29
40
  export type OpenApiOptions = Schmock.OpenApiOptions;
41
+ export type OpenApiRefPolicy = Schmock.OpenApiRefPolicy;
42
+ export type OnSchemaContext = Schmock.OnSchemaContext;
43
+ export type OnSchemaCallback = Schmock.OnSchemaCallback;
30
44
  export type OpenApiCallbackRequest = Schmock.OpenApiCallbackRequest;
31
45
  export type OpenApiCallbackOptions = Schmock.OpenApiCallbackOptions;
32
46
  export type SeedSource = Schmock.SeedSource;
@@ -36,4 +50,16 @@ export type AdapterResponse = Schmock.AdapterResponse;
36
50
  export type InterceptOptions = Schmock.InterceptOptions;
37
51
  export type InterceptHandle = Schmock.InterceptHandle;
38
52
  export type AdapterRequestOverride = Schmock.AdapterRequestOverride;
39
- //# sourceMappingURL=types.d.ts.map
53
+ export type PaginateOptions = Schmock.PaginateOptions;
54
+ export type PaginatedResponse<T> = Schmock.PaginatedResponse<T>;
55
+ export type RequestStartEvent = Schmock.RequestStartEvent;
56
+ export type RequestMatchEvent = Schmock.RequestMatchEvent;
57
+ export type RequestNotFoundEvent = Schmock.RequestNotFoundEvent;
58
+ export type RequestEndEvent = Schmock.RequestEndEvent;
59
+ export type SchmockEventMap = Schmock.SchmockEventMap;
60
+ export type SchmockEvent = Schmock.SchmockEvent;
61
+ export type ResponseParts = Schmock.ResponseParts;
62
+ export type PathPrefix = Schmock.PathPrefix;
63
+ export type FormattedErrorOptions = Schmock.FormattedErrorOptions;
64
+ export type MockRequestHandler = Schmock.MockRequestHandler;
65
+ export type RequestAdmission = Schmock.RequestAdmission;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@schmock/core",
3
3
  "description": "Core functionality for Schmock",
4
- "version": "2.4.1",
4
+ "version": "2.5.0",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -27,6 +27,11 @@
27
27
  "import": "./dist/index.js",
28
28
  "default": "./dist/index.js"
29
29
  },
30
+ "./adapter": {
31
+ "types": "./dist/adapter.d.ts",
32
+ "import": "./dist/adapter.js",
33
+ "default": "./dist/adapter.js"
34
+ },
30
35
  "./package.json": "./package.json"
31
36
  },
32
37
  "scripts": {
@@ -39,8 +44,8 @@
39
44
  "test:watch": "vitest --watch",
40
45
  "pretest:bdd": "rm -f src/*.js src/*.d.ts || true",
41
46
  "test:bdd": "vitest run --config vitest.config.bdd.ts",
42
- "lint": "biome check src/*.ts",
43
- "lint:fix": "biome check --write --unsafe src/*.ts",
47
+ "lint": "biome check src/",
48
+ "lint:fix": "biome check --write --unsafe src/",
44
49
  "check:publish": "publint && attw --pack --ignore-rules cjs-resolves-to-esm"
45
50
  },
46
51
  "license": "MIT",