@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
@@ -9,6 +9,10 @@ export declare function markResponseException<T extends ResponseLike>(response:
9
9
  export declare function getResponseException(response: ResponseLike): Error | undefined;
10
10
  export declare const HTTP_METHODS: readonly HttpMethod[];
11
11
  export declare function isHttpMethod(method: string): method is HttpMethod;
12
+ /**
13
+ * Uppercase `method` and narrow it to an {@link HttpMethod}.
14
+ * @throws InvalidHttpMethodError (code `INVALID_HTTP_METHOD`) for any other verb
15
+ */
12
16
  export declare function toHttpMethod(method: string): HttpMethod;
13
17
  export declare function normalizePath(path: string): string;
14
18
  /**
@@ -25,6 +29,28 @@ export declare function normalizePath(path: string): string;
25
29
  * spelling is preferable to silently rewriting the caller's path.
26
30
  */
27
31
  export declare function canonicalizePath(path: string): string;
32
+ /**
33
+ * Parse a `baseUrl` or namespace into its origin and canonical path prefix.
34
+ *
35
+ * - "/api/" → { origin: null, path: "/api" }
36
+ * - "api" → { origin: null, path: "/api" }
37
+ * - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
38
+ * - "https://x.com" → { origin: "https://x.com", path: "" }
39
+ *
40
+ * The path is canonicalized like request paths are (so "/café" and
41
+ * "/caf%C3%A9" are one prefix), gains a leading slash when it has none, and
42
+ * loses one trailing slash. A value containing "://" that is not a valid URL
43
+ * is read as a path.
44
+ */
45
+ export declare function parsePathPrefix(prefix: string): Schmock.PathPrefix;
46
+ /**
47
+ * Whether `path` lies under the prefix, on a segment boundary: "/api" matches
48
+ * "/api" and "/api/users" but never "/apiv2". `path` is canonicalized first,
49
+ * so a raw or an encoded spelling of the same path match alike. Only the path
50
+ * is compared; checking `prefix.origin` against the request's origin is the
51
+ * caller's job.
52
+ */
53
+ export declare function matchPathPrefix(prefix: Schmock.PathPrefix, path: string): boolean;
28
54
  /**
29
55
  * Decode a single captured path parameter.
30
56
  *
@@ -54,11 +80,16 @@ export declare function isRouteNotFound(response: {
54
80
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
55
81
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
56
82
  *
83
+ * Only the length and the status are checked, so the third element is typed
84
+ * `unknown`: a caller that reads it as headers must check it is a string
85
+ * record first (core's response parser rejects anything else with
86
+ * INVALID_RESPONSE). Rejecting such a tuple here instead would make the parser
87
+ * treat `[200, "x", null]` as plain array data and answer 200.
88
+ *
57
89
  * Known ambiguity: a length-2 numeric array whose first element happens to
58
90
  * be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
59
91
  * indistinguishable from a status tuple by shape alone. Prefer the explicit
60
92
  * status() helper or return an object response when the data could collide.
61
93
  */
62
- export declare function isStatusTuple(value: unknown): value is [number, unknown] | [number, unknown, Record<string, string>];
94
+ export declare function isStatusTuple(value: unknown): value is [number, unknown] | [number, unknown, unknown];
63
95
  export {};
64
- //# sourceMappingURL=constants.d.ts.map
package/dist/constants.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { InvalidHttpMethodError } from "./errors.js";
1
2
  export const ROUTE_NOT_FOUND_CODE = "ROUTE_NOT_FOUND";
2
3
  const RESPONSE_ORIGIN = Symbol.for("@schmock/core.response-origin");
3
4
  function setResponseOrigin(response, origin) {
@@ -44,10 +45,14 @@ export const HTTP_METHODS = [
44
45
  export function isHttpMethod(method) {
45
46
  return HTTP_METHODS.includes(method);
46
47
  }
48
+ /**
49
+ * Uppercase `method` and narrow it to an {@link HttpMethod}.
50
+ * @throws InvalidHttpMethodError (code `INVALID_HTTP_METHOD`) for any other verb
51
+ */
47
52
  export function toHttpMethod(method) {
48
53
  const upper = method.toUpperCase();
49
54
  if (!isHttpMethod(upper)) {
50
- throw new Error(`Invalid HTTP method: "${method}"`);
55
+ throw new InvalidHttpMethodError(method);
51
56
  }
52
57
  return upper;
53
58
  }
@@ -72,6 +77,13 @@ const PATH_ENCODED_ASCII = new Set([
72
77
  "}",
73
78
  ]);
74
79
  const PERCENT_TRIPLET = /^%[0-9A-Fa-f]{2}$/;
80
+ /**
81
+ * Paths made only of characters the loop below copies unchanged: printable
82
+ * ASCII minus {@link PATH_ENCODED_ASCII} and minus `%`, which always takes the
83
+ * loop so a lowercase triplet is still uppercased. `&-;` starts after `%`
84
+ * (0x25) on purpose.
85
+ */
86
+ const ALREADY_CANONICAL_PATH = /^[!$&-;=@-\]_a-z|~]*$/;
75
87
  /** UTF-8 for U+FFFD, the URL parser's substitute for a lone surrogate. */
76
88
  const ENCODED_REPLACEMENT_CHARACTER = "%EF%BF%BD";
77
89
  /**
@@ -88,6 +100,10 @@ const ENCODED_REPLACEMENT_CHARACTER = "%EF%BF%BD";
88
100
  * spelling is preferable to silently rewriting the caller's path.
89
101
  */
90
102
  export function canonicalizePath(path) {
103
+ // Fast path for the common case, which runs on every request: nothing to
104
+ // encode and no percent triplet to uppercase.
105
+ if (ALREADY_CANONICAL_PATH.test(path))
106
+ return path;
91
107
  let result = "";
92
108
  for (let index = 0; index < path.length;) {
93
109
  if (path[index] === "%" &&
@@ -115,6 +131,56 @@ export function canonicalizePath(path) {
115
131
  }
116
132
  return result;
117
133
  }
134
+ /**
135
+ * The one trailing-slash rule for a path prefix: a single trailing slash is
136
+ * dropped, so "/api/" and "/api" are the same prefix, and the root "/" becomes
137
+ * "" (matches every path).
138
+ */
139
+ function trimPrefixPath(canonicalPath) {
140
+ return canonicalPath === "/" ? "" : canonicalPath.replace(/\/$/, "");
141
+ }
142
+ /**
143
+ * Parse a `baseUrl` or namespace into its origin and canonical path prefix.
144
+ *
145
+ * - "/api/" → { origin: null, path: "/api" }
146
+ * - "api" → { origin: null, path: "/api" }
147
+ * - "https://x.com/api/v1" → { origin: "https://x.com", path: "/api/v1" }
148
+ * - "https://x.com" → { origin: "https://x.com", path: "" }
149
+ *
150
+ * The path is canonicalized like request paths are (so "/café" and
151
+ * "/caf%C3%A9" are one prefix), gains a leading slash when it has none, and
152
+ * loses one trailing slash. A value containing "://" that is not a valid URL
153
+ * is read as a path.
154
+ */
155
+ export function parsePathPrefix(prefix) {
156
+ if (prefix.includes("://")) {
157
+ try {
158
+ const url = new URL(prefix);
159
+ return {
160
+ origin: url.origin,
161
+ path: trimPrefixPath(canonicalizePath(url.pathname)),
162
+ };
163
+ }
164
+ catch {
165
+ // Not a URL after all: read it as a path below.
166
+ }
167
+ }
168
+ const rooted = prefix.startsWith("/") ? prefix : `/${prefix}`;
169
+ return { origin: null, path: trimPrefixPath(canonicalizePath(rooted)) };
170
+ }
171
+ /**
172
+ * Whether `path` lies under the prefix, on a segment boundary: "/api" matches
173
+ * "/api" and "/api/users" but never "/apiv2". `path` is canonicalized first,
174
+ * so a raw or an encoded spelling of the same path match alike. Only the path
175
+ * is compared; checking `prefix.origin` against the request's origin is the
176
+ * caller's job.
177
+ */
178
+ export function matchPathPrefix(prefix, path) {
179
+ if (prefix.path === "")
180
+ return true;
181
+ const canonicalPath = canonicalizePath(path);
182
+ return (canonicalPath === prefix.path || canonicalPath.startsWith(`${prefix.path}/`));
183
+ }
118
184
  /**
119
185
  * Decode a single captured path parameter.
120
186
  *
@@ -162,6 +228,12 @@ export function isRouteNotFound(response) {
162
228
  * Check if a value is a status tuple: [status, body] or [status, body, headers]
163
229
  * Guards against misinterpreting numeric arrays like [1, 2, 3] as tuples.
164
230
  *
231
+ * Only the length and the status are checked, so the third element is typed
232
+ * `unknown`: a caller that reads it as headers must check it is a string
233
+ * record first (core's response parser rejects anything else with
234
+ * INVALID_RESPONSE). Rejecting such a tuple here instead would make the parser
235
+ * treat `[200, "x", null]` as plain array data and answer 200.
236
+ *
165
237
  * Known ambiguity: a length-2 numeric array whose first element happens to
166
238
  * be in the HTTP-status range (e.g. [200, 300] as legitimate data) is
167
239
  * indistinguishable from a status tuple by shape alone. Prefer the explicit
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Debug logger that respects debug mode configuration
3
+ */
4
+ export declare class DebugLogger {
5
+ private enabled;
6
+ constructor(enabled?: boolean);
7
+ log(category: string, message: string, data?: unknown): void;
8
+ time(label: string): void;
9
+ timeEnd(label: string): void;
10
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Debug logger that respects debug mode configuration
3
+ */
4
+ export class DebugLogger {
5
+ enabled;
6
+ constructor(enabled = false) {
7
+ this.enabled = enabled;
8
+ }
9
+ log(category, message, data) {
10
+ if (!this.enabled)
11
+ return;
12
+ const timestamp = new Date().toISOString();
13
+ const prefix = `[${timestamp}] [SCHMOCK:${category.toUpperCase()}]`;
14
+ if (data) {
15
+ console.log(`${prefix} ${message}`, data);
16
+ }
17
+ else {
18
+ console.log(`${prefix} ${message}`);
19
+ }
20
+ }
21
+ time(label) {
22
+ if (!this.enabled)
23
+ return;
24
+ console.time(`[SCHMOCK] ${label}`);
25
+ }
26
+ timeEnd(label) {
27
+ if (!this.enabled)
28
+ return;
29
+ console.timeEnd(`[SCHMOCK] ${label}`);
30
+ }
31
+ }
@@ -0,0 +1,12 @@
1
+ type ResponseDelay = number | [number, number];
2
+ /**
3
+ * Apply configured response delay: the route's own when set, the mock's
4
+ * otherwise. Supports both fixed delays and random delays within a range. An
5
+ * abort ends the wait with the signal's reason.
6
+ */
7
+ export declare function applyResponseDelay(input: {
8
+ routeDelay?: ResponseDelay;
9
+ globalDelay?: ResponseDelay;
10
+ signal?: AbortSignal;
11
+ }): Promise<void>;
12
+ export {};
package/dist/delay.js ADDED
@@ -0,0 +1,37 @@
1
+ import { throwIfAborted } from "./abort.js";
2
+ /**
3
+ * Apply configured response delay: the route's own when set, the mock's
4
+ * otherwise. Supports both fixed delays and random delays within a range. An
5
+ * abort ends the wait with the signal's reason.
6
+ */
7
+ export async function applyResponseDelay(input) {
8
+ const { signal } = input;
9
+ const effectiveDelay = input.routeDelay ?? input.globalDelay;
10
+ if (!effectiveDelay) {
11
+ throwIfAborted(signal);
12
+ return;
13
+ }
14
+ const configuredMs = Array.isArray(effectiveDelay)
15
+ ? Math.random() * (effectiveDelay[1] - effectiveDelay[0]) +
16
+ effectiveDelay[0]
17
+ : effectiveDelay;
18
+ const ms = Math.max(0, configuredMs);
19
+ throwIfAborted(signal);
20
+ await new Promise((resolve, reject) => {
21
+ const finish = () => {
22
+ signal?.removeEventListener("abort", abort);
23
+ resolve();
24
+ };
25
+ const abort = () => {
26
+ clearTimeout(timer);
27
+ try {
28
+ throwIfAborted(signal);
29
+ }
30
+ catch (error) {
31
+ reject(error);
32
+ }
33
+ };
34
+ const timer = setTimeout(finish, ms);
35
+ signal?.addEventListener("abort", abort, { once: true });
36
+ });
37
+ }
package/dist/errors.d.ts CHANGED
@@ -42,6 +42,14 @@ export declare class InvalidResponseError extends SchmockError {
42
42
  export declare class PluginError extends SchmockError {
43
43
  constructor(pluginName: string, error: Error);
44
44
  }
45
+ /**
46
+ * Error thrown by `toHttpMethod` for a string that is not one of the HTTP
47
+ * methods Schmock routes (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`,
48
+ * `OPTIONS`), compared case-insensitively.
49
+ */
50
+ export declare class InvalidHttpMethodError extends SchmockError {
51
+ constructor(method: string);
52
+ }
45
53
  /**
46
54
  * Error thrown when route definition is invalid
47
55
  */
@@ -62,8 +70,11 @@ export declare class SchemaGenerationError extends SchmockError {
62
70
  }
63
71
  /**
64
72
  * Error thrown when resource limits are exceeded
73
+ *
74
+ * @param path - Optional location of the breach (for example the schema path
75
+ * `$.properties.items`). When given it is appended to the message and added
76
+ * to the context; without it both keep their original shape.
65
77
  */
66
78
  export declare class ResourceLimitError extends SchmockError {
67
- constructor(resource: string, limit: number, actual?: number);
79
+ constructor(resource: string, limit: number, actual?: number, path?: string);
68
80
  }
69
- //# sourceMappingURL=errors.d.ts.map
package/dist/errors.js CHANGED
@@ -94,6 +94,19 @@ export class PluginError extends SchmockError {
94
94
  this.name = "PluginError";
95
95
  }
96
96
  }
97
+ /**
98
+ * Error thrown by `toHttpMethod` for a string that is not one of the HTTP
99
+ * methods Schmock routes (`GET`, `POST`, `PUT`, `DELETE`, `PATCH`, `HEAD`,
100
+ * `OPTIONS`), compared case-insensitively.
101
+ */
102
+ export class InvalidHttpMethodError extends SchmockError {
103
+ constructor(method) {
104
+ super(`Invalid HTTP method: "${method}"`, "INVALID_HTTP_METHOD", {
105
+ method,
106
+ });
107
+ this.name = "InvalidHttpMethodError";
108
+ }
109
+ }
97
110
  /**
98
111
  * Error thrown when route definition is invalid
99
112
  */
@@ -123,10 +136,16 @@ export class SchemaGenerationError extends SchmockError {
123
136
  }
124
137
  /**
125
138
  * Error thrown when resource limits are exceeded
139
+ *
140
+ * @param path - Optional location of the breach (for example the schema path
141
+ * `$.properties.items`). When given it is appended to the message and added
142
+ * to the context; without it both keep their original shape.
126
143
  */
127
144
  export class ResourceLimitError extends SchmockError {
128
- constructor(resource, limit, actual) {
129
- super(`Resource limit exceeded for ${resource}: limit=${limit}${actual ? `, actual=${actual}` : ""}`, "RESOURCE_LIMIT_ERROR", { resource, limit, actual });
145
+ constructor(resource, limit, actual, path) {
146
+ super(`Resource limit exceeded for ${resource}: limit=${limit}${actual ? `, actual=${actual}` : ""}${path === undefined ? "" : ` at ${path}`}`, "RESOURCE_LIMIT_ERROR", path === undefined
147
+ ? { resource, limit, actual }
148
+ : { resource, limit, actual, path });
130
149
  this.name = "ResourceLimitError";
131
150
  }
132
151
  }
@@ -0,0 +1,17 @@
1
+ import type { DebugLogger } from "./debug-logger.js";
2
+ /**
3
+ * The mock's lifecycle event listeners.
4
+ *
5
+ * Every listener receives one frozen snapshot of the event, so a listener
6
+ * cannot edit what the next one (or the request) sees. A listener that throws
7
+ * or rejects is logged and never reaches the request.
8
+ */
9
+ export declare class MockEvents {
10
+ #private;
11
+ constructor(logger: DebugLogger);
12
+ on<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): void;
13
+ off<E extends Schmock.SchmockEvent>(event: E, listener: (data: Schmock.SchmockEventMap[E]) => void): void;
14
+ emit<E extends Schmock.SchmockEvent>(event: E, data: Schmock.SchmockEventMap[E]): void;
15
+ /** Remove every listener. */
16
+ clear(): void;
17
+ }
package/dist/events.js ADDED
@@ -0,0 +1,58 @@
1
+ import { errorMessage } from "./errors.js";
2
+ import { isThenable } from "./plugin-hooks.js";
3
+ /**
4
+ * The mock's lifecycle event listeners.
5
+ *
6
+ * Every listener receives one frozen snapshot of the event, so a listener
7
+ * cannot edit what the next one (or the request) sees. A listener that throws
8
+ * or rejects is logged and never reaches the request.
9
+ */
10
+ export class MockEvents {
11
+ // biome-ignore lint/complexity/noBannedTypes: internal storage for event listeners with varying signatures
12
+ #listeners = new Map();
13
+ #logger;
14
+ constructor(logger) {
15
+ this.#logger = logger;
16
+ }
17
+ on(event, listener) {
18
+ let set = this.#listeners.get(event);
19
+ if (!set) {
20
+ set = new Set();
21
+ this.#listeners.set(event, set);
22
+ }
23
+ set.add(listener);
24
+ }
25
+ off(event, listener) {
26
+ this.#listeners.get(event)?.delete(listener);
27
+ }
28
+ emit(event, data) {
29
+ const set = this.#listeners.get(event);
30
+ if (!set)
31
+ return;
32
+ const snapshot = { ...data };
33
+ if ("headers" in data) {
34
+ snapshot.headers = Object.freeze({ ...data.headers });
35
+ }
36
+ if ("params" in data) {
37
+ snapshot.params = Object.freeze({ ...data.params });
38
+ }
39
+ const eventData = Object.freeze(snapshot);
40
+ for (const listener of [...set]) {
41
+ try {
42
+ const listenerResult = listener(eventData);
43
+ if (isThenable(listenerResult)) {
44
+ void Promise.resolve(listenerResult).catch((error) => {
45
+ this.#logger.log("event", `${event} listener rejected: ${errorMessage(error)}`);
46
+ });
47
+ }
48
+ }
49
+ catch (error) {
50
+ this.#logger.log("event", `${event} listener failed: ${errorMessage(error)}`);
51
+ }
52
+ }
53
+ }
54
+ /** Remove every listener. */
55
+ clear() {
56
+ this.#listeners.clear();
57
+ }
58
+ }
@@ -0,0 +1,43 @@
1
+ /** One period of the mock between two resets. */
2
+ export interface RequestGeneration {
3
+ activeAdmissions: number;
4
+ /** Set once retired: the plugins it still owes an uninstall. */
5
+ retiredPlugins?: readonly Schmock.Plugin[];
6
+ }
7
+ type Uninstall = (plugins: readonly Schmock.Plugin[]) => void;
8
+ /**
9
+ * The mock's request generations. `reset()` retires the current one, but the
10
+ * plugins it had installed are uninstalled only once its last in-flight
11
+ * request settles, so no admitted request loses its plugins mid-flight.
12
+ */
13
+ export declare class RequestGenerations {
14
+ #private;
15
+ constructor(uninstall: Uninstall);
16
+ /**
17
+ * Whether a request's generation is still the live one. Only its requests
18
+ * emit lifecycle events and record history.
19
+ */
20
+ isCurrent(generation: RequestGeneration): boolean;
21
+ /** Count one more in-flight request in the current generation. */
22
+ admit(): RequestGeneration;
23
+ /**
24
+ * Count a request out. The last one out of a retired generation runs the
25
+ * uninstall that generation owes.
26
+ */
27
+ release(generation: RequestGeneration): void;
28
+ /** Start a new generation; the previous one is returned for `retire()`. */
29
+ advance(): RequestGeneration;
30
+ /**
31
+ * Retire a generation that owes `plugins` an uninstall: at once when no
32
+ * request of it is in flight, otherwise when its last request settles.
33
+ */
34
+ retire(generation: RequestGeneration, plugins: readonly Schmock.Plugin[]): void;
35
+ /**
36
+ * Run the uninstall a retired generation still owes this plugin object, now,
37
+ * before it is installed again. Left to the retired generation, it would run
38
+ * when that generation's last request settles — after the new install() —
39
+ * and tear down the live installation.
40
+ */
41
+ uninstallBeforeReinstall(plugin: Schmock.Plugin): void;
42
+ }
43
+ export {};
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The mock's request generations. `reset()` retires the current one, but the
3
+ * plugins it had installed are uninstalled only once its last in-flight
4
+ * request settles, so no admitted request loses its plugins mid-flight.
5
+ */
6
+ export class RequestGenerations {
7
+ #current = { activeAdmissions: 0 };
8
+ /** Retired generations whose uninstall waits for in-flight requests. */
9
+ #retired = new Set();
10
+ #uninstall;
11
+ constructor(uninstall) {
12
+ this.#uninstall = uninstall;
13
+ }
14
+ /**
15
+ * Whether a request's generation is still the live one. Only its requests
16
+ * emit lifecycle events and record history.
17
+ */
18
+ isCurrent(generation) {
19
+ return generation === this.#current;
20
+ }
21
+ /** Count one more in-flight request in the current generation. */
22
+ admit() {
23
+ const generation = this.#current;
24
+ generation.activeAdmissions += 1;
25
+ return generation;
26
+ }
27
+ /**
28
+ * Count a request out. The last one out of a retired generation runs the
29
+ * uninstall that generation owes.
30
+ */
31
+ release(generation) {
32
+ generation.activeAdmissions -= 1;
33
+ if (generation.activeAdmissions === 0 &&
34
+ generation.retiredPlugins !== undefined) {
35
+ const plugins = generation.retiredPlugins;
36
+ generation.retiredPlugins = undefined;
37
+ this.#retired.delete(generation);
38
+ this.#uninstall(plugins);
39
+ }
40
+ }
41
+ /** Start a new generation; the previous one is returned for `retire()`. */
42
+ advance() {
43
+ const previous = this.#current;
44
+ this.#current = { activeAdmissions: 0 };
45
+ return previous;
46
+ }
47
+ /**
48
+ * Retire a generation that owes `plugins` an uninstall: at once when no
49
+ * request of it is in flight, otherwise when its last request settles.
50
+ */
51
+ retire(generation, plugins) {
52
+ generation.retiredPlugins = plugins;
53
+ if (generation.activeAdmissions === 0) {
54
+ generation.retiredPlugins = undefined;
55
+ this.#uninstall(plugins);
56
+ return;
57
+ }
58
+ this.#retired.add(generation);
59
+ }
60
+ /**
61
+ * Run the uninstall a retired generation still owes this plugin object, now,
62
+ * before it is installed again. Left to the retired generation, it would run
63
+ * when that generation's last request settles — after the new install() —
64
+ * and tear down the live installation.
65
+ */
66
+ uninstallBeforeReinstall(plugin) {
67
+ for (const generation of this.#retired) {
68
+ const pending = generation.retiredPlugins;
69
+ if (!pending?.includes(plugin))
70
+ continue;
71
+ generation.retiredPlugins = pending.filter((retired) => retired !== plugin);
72
+ this.#uninstall([plugin]);
73
+ }
74
+ }
75
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Header names whose value is a credential, in lowercase. Debug logs replace
3
+ * their values with "[redacted]" (see {@link redactHeaders}) and keep the name,
4
+ * so a log still shows the header was sent. The CLI's admin history masks the
5
+ * same set.
6
+ */
7
+ export declare const SENSITIVE_HEADER_NAMES: ReadonlySet<string>;
8
+ /**
9
+ * Look a header up by name, ignoring case. Response headers keep the casing a
10
+ * route gave them, so `headers["content-type"]` can miss a `Content-Type`.
11
+ * @returns the first matching value, or `undefined`
12
+ */
13
+ export declare function getHeader(headers: Readonly<Record<string, string>> | undefined, name: string): string | undefined;
14
+ /**
15
+ * Whether a header of that name is present, ignoring case, whatever its value.
16
+ * Unlike `getHeader(...) !== undefined`, a key present with a non-string value
17
+ * counts, so the response normalizer still gets to reject that value.
18
+ */
19
+ export declare function hasHeader(headers: Readonly<Record<string, unknown>>, name: string): boolean;
20
+ /**
21
+ * Replace the value of every {@link SENSITIVE_HEADER_NAMES} header with
22
+ * "[redacted]", matching names case-insensitively.
23
+ *
24
+ * Copy-on-write: the input is never mutated, and when nothing is sensitive the
25
+ * same object is returned.
26
+ */
27
+ export declare function redactHeaders(headers: Record<string, string>): Record<string, string>;
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Header names whose value is a credential, in lowercase. Debug logs replace
3
+ * their values with "[redacted]" (see {@link redactHeaders}) and keep the name,
4
+ * so a log still shows the header was sent. The CLI's admin history masks the
5
+ * same set.
6
+ */
7
+ export const SENSITIVE_HEADER_NAMES = new Set([
8
+ "authorization",
9
+ "proxy-authorization",
10
+ "cookie",
11
+ "set-cookie",
12
+ "x-api-key",
13
+ "x-auth-token",
14
+ "x-schmock-admin-token",
15
+ ]);
16
+ const REDACTED_HEADER_VALUE = "[redacted]";
17
+ /**
18
+ * Look a header up by name, ignoring case. Response headers keep the casing a
19
+ * route gave them, so `headers["content-type"]` can miss a `Content-Type`.
20
+ * @returns the first matching value, or `undefined`
21
+ */
22
+ export function getHeader(headers, name) {
23
+ if (headers === undefined)
24
+ return undefined;
25
+ const wanted = name.toLowerCase();
26
+ for (const key of Object.keys(headers)) {
27
+ if (key.toLowerCase() === wanted)
28
+ return headers[key];
29
+ }
30
+ return undefined;
31
+ }
32
+ /**
33
+ * Whether a header of that name is present, ignoring case, whatever its value.
34
+ * Unlike `getHeader(...) !== undefined`, a key present with a non-string value
35
+ * counts, so the response normalizer still gets to reject that value.
36
+ */
37
+ export function hasHeader(headers, name) {
38
+ const wanted = name.toLowerCase();
39
+ return Object.keys(headers).some((key) => key.toLowerCase() === wanted);
40
+ }
41
+ /**
42
+ * Replace the value of every {@link SENSITIVE_HEADER_NAMES} header with
43
+ * "[redacted]", matching names case-insensitively.
44
+ *
45
+ * Copy-on-write: the input is never mutated, and when nothing is sensitive the
46
+ * same object is returned.
47
+ */
48
+ export function redactHeaders(headers) {
49
+ let redacted;
50
+ for (const name of Object.keys(headers)) {
51
+ if (!SENSITIVE_HEADER_NAMES.has(name.toLowerCase()))
52
+ continue;
53
+ redacted ??= { ...headers };
54
+ redacted[name] = REDACTED_HEADER_VALUE;
55
+ }
56
+ return redacted ?? headers;
57
+ }
package/dist/helpers.d.ts CHANGED
@@ -1,10 +1,10 @@
1
- export declare function notFound(message?: string | object): [number, object];
2
- export declare function badRequest(message?: string | object): [number, object];
3
- export declare function unauthorized(message?: string | object): [number, object];
4
- export declare function forbidden(message?: string | object): [number, object];
5
- export declare function serverError(message?: string | object): [number, object];
6
- export declare function created(body: object): [number, object];
7
- export declare function noContent(): [number, null];
1
+ export declare function notFound(message?: string | object): [404, object];
2
+ export declare function badRequest(message?: string | object): [400, object];
3
+ export declare function unauthorized(message?: string | object): [401, object];
4
+ export declare function forbidden(message?: string | object): [403, object];
5
+ export declare function serverError(message?: string | object): [500, object];
6
+ export declare function created(body: object): [201, object];
7
+ export declare function noContent(): [204, null];
8
8
  /**
9
9
  * Slice `items` into a page envelope.
10
10
  *
@@ -12,7 +12,6 @@ export declare function noContent(): [number, null];
12
12
  * page 1 and a page size of 10) so a fractional, negative, NaN or infinite
13
13
  * option can never produce a nonsensical slice or a negative `totalPages`. The
14
14
  * returned envelope always echoes the NORMALIZED values, so it is internally
15
- * consistent with `data`.
15
+ * consistent with `data`. `items` is only read, so a readonly array is fine.
16
16
  */
17
- export declare function paginate<T>(items: T[], options?: Schmock.PaginateOptions): Schmock.PaginatedResponse<T>;
18
- //# sourceMappingURL=helpers.d.ts.map
17
+ export declare function paginate<T>(items: readonly T[], options?: Schmock.PaginateOptions): Schmock.PaginatedResponse<T>;
package/dist/helpers.js CHANGED
@@ -1,4 +1,7 @@
1
1
  /// <reference path="../schmock.d.ts" />
2
+ // Each helper returns its literal status (`[404, object]`, not
3
+ // `[number, object]`), as docs/api.md documents: a literal tuple is still
4
+ // assignable to `[number, object]`, and callers can destructure a typed status.
2
5
  export function notFound(message = "Not Found") {
3
6
  const body = typeof message === "string" ? { message } : message;
4
7
  return [404, body];
@@ -39,7 +42,7 @@ function positiveInteger(value, fallback) {
39
42
  * page 1 and a page size of 10) so a fractional, negative, NaN or infinite
40
43
  * option can never produce a nonsensical slice or a negative `totalPages`. The
41
44
  * returned envelope always echoes the NORMALIZED values, so it is internally
42
- * consistent with `data`.
45
+ * consistent with `data`. `items` is only read, so a readonly array is fine.
43
46
  */
44
47
  export function paginate(items, options = {}) {
45
48
  const page = positiveInteger(options.page, 1);