@schmock/core 2.4.0 → 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
@@ -0,0 +1,166 @@
1
+ import { errorMessage, SchmockError } from "./errors.js";
2
+ import { DEFAULT_MAX_BODY_SIZE, serveNodeRequest } from "./http-helpers.js";
3
+ /**
4
+ * The standalone HTTP server behind `mock.listen()` / `mock.close()`.
5
+ *
6
+ * Owns the start and close state machines: at most one server is running or
7
+ * starting, a `close()` during start-up cancels the start, and a new start
8
+ * waits for every earlier server to finish closing (the close barrier) so a
9
+ * restart on the same port never races the old socket.
10
+ *
11
+ * `node:http` is imported lazily, on the first `listen()`, so a browser bundle
12
+ * that never listens never pulls it in (issue #395).
13
+ */
14
+ export class NodeServerController {
15
+ #server;
16
+ #pendingStart;
17
+ #closeBarrier;
18
+ #admitRequest;
19
+ #logger;
20
+ constructor(options) {
21
+ this.#admitRequest = options.admitRequest;
22
+ this.#logger = options.logger;
23
+ }
24
+ listen(port, hostname) {
25
+ if (this.#server || this.#pendingStart) {
26
+ throw new SchmockError("Server is already running", "SERVER_ALREADY_RUNNING");
27
+ }
28
+ let resolveStart = (_info) => { };
29
+ let rejectStart = (_error) => { };
30
+ const startPromise = new Promise((resolve, reject) => {
31
+ resolveStart = resolve;
32
+ rejectStart = reject;
33
+ });
34
+ const operation = {
35
+ token: Symbol("schmock.server.start"),
36
+ port,
37
+ hostname,
38
+ resolve: resolveStart,
39
+ reject: rejectStart,
40
+ settled: false,
41
+ };
42
+ this.#pendingStart = operation;
43
+ const closeBarrier = this.#closeBarrier ?? Promise.resolve();
44
+ void closeBarrier
45
+ // Lazy-load node:http so browser bundles never pull it in (issue #395).
46
+ // The rejection handler must sit on the import() expression itself:
47
+ // esbuild (and so the Angular application builder) leaves a dynamic
48
+ // import unresolved only when that expression handles its own failure,
49
+ // and the outer .catch() below does not count. Without it a
50
+ // `platform: "browser"` build fails with `Could not resolve "node:http"`.
51
+ .then(() => import("node:http").catch((error) => {
52
+ throw error;
53
+ }))
54
+ .then(({ createServer }) => {
55
+ if (!this.#ownsServerStart(operation))
56
+ return;
57
+ this.#startHttpServer(operation, createServer);
58
+ })
59
+ .catch((error) => {
60
+ this.#rejectServerStart(operation, error);
61
+ });
62
+ return startPromise;
63
+ }
64
+ close() {
65
+ this.#cancelServerStart();
66
+ const server = this.#server;
67
+ if (!server)
68
+ return;
69
+ this.#server = undefined;
70
+ this.#beginServerClose(server);
71
+ this.#logger.log("server", "Server stopped");
72
+ }
73
+ #ownsServerStart(operation) {
74
+ return this.#pendingStart === operation && !operation.settled;
75
+ }
76
+ #startHttpServer(operation, createServer) {
77
+ const httpServer = createServer((req, res) => {
78
+ // Admitted on arrival, before the request is parsed, so a reset() while
79
+ // its body uploads neither changes its routes nor uninstalls its plugins.
80
+ const admittedRequest = this.#admitRequest();
81
+ void serveNodeRequest(req, res, {
82
+ handle: admittedRequest.handle,
83
+ maxBodySize: DEFAULT_MAX_BODY_SIZE,
84
+ }).finally(() => admittedRequest.release());
85
+ });
86
+ operation.server = httpServer;
87
+ const handleStartupError = (error) => {
88
+ this.#rejectServerStart(operation, error);
89
+ };
90
+ httpServer.once("error", handleStartupError);
91
+ // Once listening, a server-level 'error' (an accept failure such as
92
+ // EMFILE) must still have a listener: with none, Node rethrows it as an
93
+ // uncaught exception and takes the whole test runner down.
94
+ const reportServerError = (error) => {
95
+ this.#logger.log("server", `Server error: ${errorMessage(error)}`);
96
+ };
97
+ try {
98
+ httpServer.listen(operation.port, operation.hostname, () => {
99
+ // Attach the permanent reporter BEFORE dropping the startup handler:
100
+ // the other order leaves a window with no 'error' listener at all.
101
+ httpServer.on("error", reportServerError);
102
+ httpServer.off("error", handleStartupError);
103
+ if (!this.#ownsServerStart(operation)) {
104
+ this.#beginServerClose(httpServer);
105
+ return;
106
+ }
107
+ const addr = httpServer.address();
108
+ const actualPort = addr !== null && typeof addr === "object"
109
+ ? addr.port
110
+ : operation.port;
111
+ const info = { port: actualPort, hostname: operation.hostname };
112
+ operation.settled = true;
113
+ this.#pendingStart = undefined;
114
+ this.#server = httpServer;
115
+ this.#logger.log("server", `Listening on ${operation.hostname}:${actualPort}`);
116
+ operation.resolve(info);
117
+ });
118
+ }
119
+ catch (error) {
120
+ httpServer.off("error", handleStartupError);
121
+ this.#rejectServerStart(operation, error);
122
+ }
123
+ }
124
+ #rejectServerStart(operation, error) {
125
+ if (operation.settled)
126
+ return;
127
+ operation.settled = true;
128
+ if (this.#pendingStart === operation) {
129
+ this.#pendingStart = undefined;
130
+ }
131
+ if (operation.server) {
132
+ this.#beginServerClose(operation.server);
133
+ }
134
+ operation.reject(error);
135
+ }
136
+ #cancelServerStart() {
137
+ const operation = this.#pendingStart;
138
+ if (!operation)
139
+ return;
140
+ this.#rejectServerStart(operation, new SchmockError("Server start was cancelled", "SERVER_START_CANCELLED"));
141
+ }
142
+ #beginServerClose(server) {
143
+ const closePromise = new Promise((resolve) => {
144
+ try {
145
+ server.close(() => resolve());
146
+ }
147
+ catch {
148
+ resolve();
149
+ }
150
+ });
151
+ try {
152
+ server.closeAllConnections();
153
+ }
154
+ catch {
155
+ // A not-yet-listening server has no connections to close.
156
+ }
157
+ const previousBarrier = this.#closeBarrier ?? Promise.resolve();
158
+ const combinedBarrier = Promise.all([previousBarrier, closePromise]).then(() => undefined);
159
+ this.#closeBarrier = combinedBarrier;
160
+ void combinedBarrier.finally(() => {
161
+ if (this.#closeBarrier === combinedBarrier) {
162
+ this.#closeBarrier = undefined;
163
+ }
164
+ });
165
+ }
166
+ }
package/dist/parser.d.ts CHANGED
@@ -17,4 +17,3 @@ export interface ParsedRoute {
17
17
  * // => { method: 'GET', path: '/users/:id', pattern: /^\/users\/([^/]+)$/, params: ['id'] }
18
18
  */
19
19
  export declare function parseRouteKey(routeKey: string): ParsedRoute;
20
- //# sourceMappingURL=parser.d.ts.map
package/dist/parser.js CHANGED
@@ -22,32 +22,155 @@ export function parseRouteKey(routeKey) {
22
22
  throw new RouteParseError(routeKey, 'Expected format: "METHOD /path" (e.g., "GET /users")');
23
23
  }
24
24
  const [, method, rawPath] = match;
25
- // Canonicalize once, here, so `path`, `pattern`, the duplicate check, the
25
+ // Tokenize once, then derive `path`, `pattern` and `params` from the same
26
+ // tokens. `path` is canonicalized here so the duplicate check, the
26
27
  // static-route Map key and getRoutes() all agree on one spelling: the
27
28
  // percent-encoded transport form with a single trailing slash stripped.
28
- const path = normalizePath(canonicalizePath(rawPath));
29
- // Parameter names are restricted to [A-Za-z0-9_-] so that surrounding
30
- // literals (".json", brackets, parens, etc.) terminate the name and can
31
- // be escaped without bleeding into the parameter regex. Build the
32
- // pattern by splitting the path on the param marker, escaping each
33
- // literal segment, then substituting the capture group for each :name.
34
- const params = [];
35
- const regexPath = path
36
- .split(/(:[a-zA-Z0-9_-]+)/g)
37
- .map((segment) => {
38
- const paramMatch = segment.match(/^:([a-zA-Z0-9_-]+)$/);
39
- if (paramMatch) {
40
- params.push(paramMatch[1]);
41
- return "([^/]+)";
42
- }
43
- return segment.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
44
- })
45
- .join("");
46
- const pattern = new RegExp(`^${regexPath}$`);
29
+ const tokens = tokenizeRoutePath(routeKey, normalizePath(rawPath));
30
+ const params = tokens.flatMap((token) => token.kind === "param" ? [token.name] : []);
47
31
  return {
48
32
  method: toHttpMethod(method),
49
- path,
50
- pattern,
33
+ path: serializeRoutePath(tokens, params.length > 0),
34
+ pattern: new RegExp(`^${compileRoutePattern(tokens)}$`),
51
35
  params,
52
36
  };
53
37
  }
38
+ const PLAIN_NAME_CHAR = /^[A-Za-z0-9_-]$/;
39
+ const TRAILING_HYPHENS = /-+$/;
40
+ /** A bare colon that a later parse would read as the start of a parameter. */
41
+ const COLON_BEFORE_NAME = /:(?=[A-Za-z0-9_-])/g;
42
+ /**
43
+ * Read the parameter whose ':' sits at `index`, or undefined when that colon
44
+ * is a literal: nothing name-like follows it, or the quote is unclosed or
45
+ * empty (both of which were literal text before quoted names existed).
46
+ */
47
+ function readParam(path, index) {
48
+ if (path[index] !== ":")
49
+ return undefined;
50
+ if (path[index + 1] === '"') {
51
+ const close = path.indexOf('"', index + 2);
52
+ if (close <= index + 2)
53
+ return undefined;
54
+ return { name: path.slice(index + 2, close), quoted: true, end: close + 1 };
55
+ }
56
+ let end = index + 1;
57
+ while (end < path.length && PLAIN_NAME_CHAR.test(path[end]))
58
+ end += 1;
59
+ if (end === index + 1)
60
+ return undefined;
61
+ return { name: path.slice(index + 1, end), quoted: false, end };
62
+ }
63
+ function tokenizeRoutePath(routeKey, path) {
64
+ const tokens = [];
65
+ let literal = "";
66
+ const pushParam = (name, quoted) => {
67
+ if (literal) {
68
+ tokens.push({ kind: "literal", text: literal });
69
+ literal = "";
70
+ }
71
+ const previous = tokens[tokens.length - 1];
72
+ if (previous?.kind === "param") {
73
+ throw new RouteParseError(routeKey, `Parameters ":${previous.name}" and ":${name}" are adjacent, so nothing decides where one ends. Put a literal between them (e.g. ":${previous.name}-:${name}"), or write a literal colon as "\\:".`);
74
+ }
75
+ tokens.push({ kind: "param", name, quoted });
76
+ };
77
+ let index = 0;
78
+ while (index < path.length) {
79
+ if (path[index] === "\\" && path[index + 1] === ":") {
80
+ literal += ":";
81
+ index += 2;
82
+ continue;
83
+ }
84
+ const param = readParam(path, index);
85
+ if (!param) {
86
+ literal += path[index];
87
+ index += 1;
88
+ continue;
89
+ }
90
+ if (!param.quoted && readParam(path, param.end)) {
91
+ const name = param.name.replace(TRAILING_HYPHENS, "");
92
+ if (name) {
93
+ pushParam(name, false);
94
+ }
95
+ else {
96
+ // A name made only of hyphens leaves nothing to name: the colon is
97
+ // literal text, as it would be without the parameter that follows.
98
+ literal += ":";
99
+ }
100
+ literal += param.name.slice(name.length);
101
+ }
102
+ else {
103
+ pushParam(param.name, param.quoted);
104
+ }
105
+ index = param.end;
106
+ }
107
+ if (literal)
108
+ tokens.push({ kind: "literal", text: literal });
109
+ return tokens;
110
+ }
111
+ /**
112
+ * The route's canonical spelling: literals in the percent-encoded transport
113
+ * form (see canonicalizePath) and parameters as written. A route with
114
+ * parameters keeps a `\:` escape wherever a bare colon would start a
115
+ * parameter. A route without parameters is spelled as its literal path,
116
+ * because that string is also the static-route key a request is looked up by.
117
+ */
118
+ function serializeRoutePath(tokens, hasParams) {
119
+ return tokens
120
+ .map((token) => {
121
+ if (token.kind === "param") {
122
+ return token.quoted ? `:"${token.name}"` : `:${token.name}`;
123
+ }
124
+ const canonical = canonicalizePath(token.text);
125
+ return hasParams
126
+ ? canonical.replace(COLON_BEFORE_NAME, "\\:")
127
+ : canonical;
128
+ })
129
+ .join("");
130
+ }
131
+ function escapeRegExp(text) {
132
+ return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
133
+ }
134
+ function escapeCharClass(char) {
135
+ return char.replace(/[\\\]^-]/g, "\\$&");
136
+ }
137
+ /**
138
+ * Compile tokens to a regex source.
139
+ *
140
+ * A parameter that is the only one in its path segment, or the last one,
141
+ * captures `[^/]+` exactly as it always has — so `:name.json` still matches
142
+ * "report.v2.json" greedily. A parameter followed in the same segment by a
143
+ * literal and then another parameter also excludes that literal's first
144
+ * character, so it can only end at the separator:
145
+ * `:year-:month-:day` → `([^/-]+)-([^/-]+)-([^/]+)`. A percent-encoded
146
+ * literal is excluded whole rather than by its "%": `:first :last` →
147
+ * `((?:(?!%20)[^/])+)%20([^/]+)`. Without the exclusion
148
+ * every capture could end anywhere, and a long non-matching segment
149
+ * backtracked quadratically (two parameters) or cubically (three).
150
+ */
151
+ function compileRoutePattern(tokens) {
152
+ return tokens
153
+ .map((token, index) => {
154
+ if (token.kind === "literal") {
155
+ return escapeRegExp(canonicalizePath(token.text));
156
+ }
157
+ const next = tokens[index + 1];
158
+ if (next?.kind === "literal" &&
159
+ !next.text.includes("/") &&
160
+ tokens[index + 2]?.kind === "param") {
161
+ const separator = canonicalizePath(next.text);
162
+ // A separator that canonicalizes to a percent triplet (a space, a
163
+ // brace, any non-ASCII character) starts with "%", and excluding "%"
164
+ // itself would forbid every encoded character in the capture ("José"
165
+ // is "Jos%C3%A9"). Exclude the whole encoded literal instead: the
166
+ // capture still cannot contain the separator, so it can only end at
167
+ // its first occurrence and the match stays linear.
168
+ if (separator.startsWith("%")) {
169
+ return `((?:(?!${escapeRegExp(separator)})[^/])+)`;
170
+ }
171
+ return `([^/${escapeCharClass(separator[0])}]+)`;
172
+ }
173
+ return "([^/]+)";
174
+ })
175
+ .join("");
176
+ }
@@ -0,0 +1,40 @@
1
+ import type { DebugLogger } from "./debug-logger.js";
2
+ export declare function isThenable(value: unknown): value is PromiseLike<unknown>;
3
+ /**
4
+ * Reject, when it is piped, a plugin that could never work: one without a
5
+ * `process` function answered every matched request with a 500 instead.
6
+ * Only shapes that already failed are rejected, so no working setup breaks.
7
+ */
8
+ export declare function assertValidPlugin(plugin: unknown): asserts plugin is Schmock.Plugin;
9
+ /** The live reads a hook's instance forwards to the mock. */
10
+ export interface HookReadAccess {
11
+ history(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord[];
12
+ called(method?: Schmock.HttpMethod, path?: string): boolean;
13
+ callCount(method?: Schmock.HttpMethod, path?: string): number;
14
+ lastRequest(method?: Schmock.HttpMethod, path?: string): Schmock.RequestRecord | undefined;
15
+ getRoutes(): Schmock.RouteInfo[];
16
+ getState(): Record<string, unknown>;
17
+ }
18
+ type RouteRegistrar = (route: Schmock.RouteKey, generator: Schmock.Generator, config: Schmock.RouteConfig) => void;
19
+ /**
20
+ * Run a plugin's `install()` against an expiring facade that may register
21
+ * routes. A Promise returned from `install()` is rejected: the routes it would
22
+ * register later could not be rolled back. The caller owns the rollback of
23
+ * whatever the hook registered before it threw.
24
+ */
25
+ export declare function runInstallHook(input: {
26
+ plugin: Schmock.Plugin;
27
+ reads: HookReadAccess;
28
+ registerRoute: RouteRegistrar;
29
+ logger: DebugLogger;
30
+ }): void;
31
+ /**
32
+ * Run `uninstall()` for each plugin, last piped first. A failing hook is
33
+ * logged and never stops the others.
34
+ */
35
+ export declare function runUninstallHooks(input: {
36
+ plugins: readonly Schmock.Plugin[];
37
+ reads: HookReadAccess;
38
+ logger: DebugLogger;
39
+ }): void;
40
+ export {};
@@ -0,0 +1,192 @@
1
+ import { errorMessage, SchmockError } from "./errors.js";
2
+ const PLUGIN_HOOK_ERROR_CODES = {
3
+ install: {
4
+ expired: "PLUGIN_INSTALL_SCOPE_EXPIRED",
5
+ unsupported: "PLUGIN_INSTALL_OPERATION_UNSUPPORTED",
6
+ },
7
+ uninstall: {
8
+ expired: "PLUGIN_UNINSTALL_SCOPE_EXPIRED",
9
+ unsupported: "PLUGIN_UNINSTALL_OPERATION_UNSUPPORTED",
10
+ },
11
+ };
12
+ /**
13
+ * Optional hooks that break the plugin when set to a truthy non-function:
14
+ * `install` threw a TypeError from pipe() and `beforeRequest` failed every
15
+ * matched request. Falsy values (`onError: null`, `install: false`) are how
16
+ * callers switch a hook off and keep working; `onError`/`uninstall` failures
17
+ * only ever surfaced on paths that already fail or log, so they are left alone.
18
+ */
19
+ const EAGER_PLUGIN_HOOKS = ["install", "beforeRequest"];
20
+ export function isThenable(value) {
21
+ return (typeof value === "object" &&
22
+ value !== null &&
23
+ "then" in value &&
24
+ typeof value.then === "function");
25
+ }
26
+ function describeInvalidPlugin(plugin) {
27
+ if ((typeof plugin !== "object" && typeof plugin !== "function") ||
28
+ plugin === null) {
29
+ return "expected a plugin object";
30
+ }
31
+ if (typeof Reflect.get(plugin, "process") !== "function") {
32
+ return "process must be a function";
33
+ }
34
+ for (const hook of EAGER_PLUGIN_HOOKS) {
35
+ const value = Reflect.get(plugin, hook);
36
+ if (value && typeof value !== "function") {
37
+ return `${hook} must be a function when set`;
38
+ }
39
+ }
40
+ return undefined;
41
+ }
42
+ /**
43
+ * Reject, when it is piped, a plugin that could never work: one without a
44
+ * `process` function answered every matched request with a 500 instead.
45
+ * Only shapes that already failed are rejected, so no working setup breaks.
46
+ */
47
+ export function assertValidPlugin(plugin) {
48
+ const reason = describeInvalidPlugin(plugin);
49
+ if (reason === undefined)
50
+ return;
51
+ const name = typeof plugin === "object" && plugin !== null
52
+ ? Reflect.get(plugin, "name")
53
+ : undefined;
54
+ const label = typeof name === "string" && name.length > 0 ? ` "${name}"` : "";
55
+ throw new SchmockError(`Invalid plugin${label}: ${reason}`, "PLUGIN_INVALID", {
56
+ plugin: typeof name === "string" ? name : undefined,
57
+ reason,
58
+ });
59
+ }
60
+ /**
61
+ * The instance a plugin hook receives. Reads are live; route registration is
62
+ * allowed only when the hook passes `registerRoute` (install does, uninstall
63
+ * does not); every other operation is rejected. `isActive` expires the
64
+ * facade when the hook returns, so a retained reference cannot act later.
65
+ */
66
+ function createHookFacade(input) {
67
+ const { plugin, hook, isActive, reads, registerRoute } = input;
68
+ const codes = PLUGIN_HOOK_ERROR_CODES[hook];
69
+ const requireScope = () => {
70
+ if (isActive())
71
+ return;
72
+ throw new SchmockError(`Plugin "${plugin.name}" used its ${hook} instance outside ${hook}()`, codes.expired, { plugin: plugin.name });
73
+ };
74
+ const reject = (operation) => {
75
+ requireScope();
76
+ throw new SchmockError(`Plugin "${plugin.name}" cannot call ${operation} during ${hook}()`, codes.unsupported, { operation, plugin: plugin.name });
77
+ };
78
+ let facade;
79
+ const defineRoute = (route, generator, config = {}) => {
80
+ if (!registerRoute)
81
+ return reject("route registration");
82
+ requireScope();
83
+ registerRoute(route, generator, config);
84
+ return facade;
85
+ };
86
+ facade = Object.assign(defineRoute, {
87
+ pipe: () => reject("pipe()"),
88
+ handle: () => reject("handle()"),
89
+ history: (method, path) => {
90
+ requireScope();
91
+ return reads.history(method, path);
92
+ },
93
+ called: (method, path) => {
94
+ requireScope();
95
+ return reads.called(method, path);
96
+ },
97
+ callCount: (method, path) => {
98
+ requireScope();
99
+ return reads.callCount(method, path);
100
+ },
101
+ lastRequest: (method, path) => {
102
+ requireScope();
103
+ return reads.lastRequest(method, path);
104
+ },
105
+ reset: () => reject("reset()"),
106
+ resetHistory: () => reject("resetHistory()"),
107
+ resetState: () => reject("resetState()"),
108
+ on: () => reject("on()"),
109
+ off: () => reject("off()"),
110
+ getRoutes: () => {
111
+ requireScope();
112
+ return reads.getRoutes();
113
+ },
114
+ getState: () => {
115
+ requireScope();
116
+ return reads.getState();
117
+ },
118
+ listen: () => reject("listen()"),
119
+ close: () => reject("close()"),
120
+ intercept: () => reject("intercept()"),
121
+ });
122
+ return facade;
123
+ }
124
+ /**
125
+ * Run a plugin's `install()` against an expiring facade that may register
126
+ * routes. A Promise returned from `install()` is rejected: the routes it would
127
+ * register later could not be rolled back. The caller owns the rollback of
128
+ * whatever the hook registered before it threw.
129
+ */
130
+ export function runInstallHook(input) {
131
+ const { plugin, logger } = input;
132
+ if (!plugin.install)
133
+ return;
134
+ let installActive = true;
135
+ const installFacade = createHookFacade({
136
+ plugin,
137
+ hook: "install",
138
+ isActive: () => installActive,
139
+ reads: input.reads,
140
+ registerRoute: input.registerRoute,
141
+ });
142
+ try {
143
+ const installResult = plugin.install(installFacade);
144
+ installActive = false;
145
+ if (isThenable(installResult)) {
146
+ void Promise.resolve(installResult).catch((error) => {
147
+ logger.log("plugin", `Rejected async install for ${plugin.name}: ${errorMessage(error)}`);
148
+ });
149
+ throw new SchmockError(`Plugin "${plugin.name}" returned a Promise from install()`, "PLUGIN_ASYNC_INSTALL_UNSUPPORTED", { plugin: plugin.name });
150
+ }
151
+ }
152
+ finally {
153
+ installActive = false;
154
+ }
155
+ }
156
+ /**
157
+ * Run `uninstall()` for each plugin, last piped first. A failing hook is
158
+ * logged and never stops the others.
159
+ */
160
+ export function runUninstallHooks(input) {
161
+ const { plugins, logger } = input;
162
+ for (let index = plugins.length - 1; index >= 0; index -= 1) {
163
+ const plugin = plugins[index];
164
+ if (!plugin.uninstall)
165
+ continue;
166
+ // Cleanup gets a read-only, expiring instance: through the live one a
167
+ // plugin could pipe plugins or register routes into the mock that
168
+ // reset() just cleared.
169
+ let uninstallActive = true;
170
+ const uninstallFacade = createHookFacade({
171
+ plugin,
172
+ hook: "uninstall",
173
+ isActive: () => uninstallActive,
174
+ reads: input.reads,
175
+ });
176
+ try {
177
+ const uninstallResult = plugin.uninstall(uninstallFacade);
178
+ if (isThenable(uninstallResult)) {
179
+ void Promise.resolve(uninstallResult).catch((error) => {
180
+ logger.log("plugin", `Async uninstall for ${plugin.name} failed: ${errorMessage(error)}`);
181
+ });
182
+ logger.log("plugin", `Plugin ${plugin.name} returned an unsupported Promise from uninstall()`);
183
+ }
184
+ }
185
+ catch (error) {
186
+ logger.log("plugin", `Plugin ${plugin.name} uninstall failed: ${errorMessage(error)}`);
187
+ }
188
+ finally {
189
+ uninstallActive = false;
190
+ }
191
+ }
192
+ }
@@ -19,4 +19,3 @@ export declare function recoverGeneratorError(plugins: readonly Schmock.Plugin[]
19
19
  */
20
20
  export declare function runPluginPipeline(plugins: readonly Schmock.Plugin[], context: Schmock.PluginContext, initialResponse: unknown, logger: PipelineLogger, signal?: AbortSignal | undefined): Promise<PipelineResult>;
21
21
  export {};
22
- //# sourceMappingURL=plugin-pipeline.d.ts.map
@@ -7,6 +7,27 @@ function isPluginResult(value) {
7
7
  typeof value.context === "object" &&
8
8
  value.context !== null);
9
9
  }
10
+ /** The failure a plugin hook raises by returning something that is not a PluginResult. */
11
+ function invalidPluginResultError(pluginName) {
12
+ return new PluginError(pluginName, new Error("didn't return valid result"));
13
+ }
14
+ function isAttributedTo(error, pluginName) {
15
+ const context = error.context;
16
+ return (typeof context === "object" &&
17
+ context !== null &&
18
+ "pluginName" in context &&
19
+ context.pluginName === pluginName);
20
+ }
21
+ /**
22
+ * Attribute an unrecovered failure to the plugin that raised it, once: an
23
+ * error that already is that plugin's PluginError (an invalid result) is not
24
+ * wrapped a second time.
25
+ */
26
+ function toPluginError(pluginName, error) {
27
+ return error instanceof PluginError && isAttributedTo(error, pluginName)
28
+ ? error
29
+ : new PluginError(pluginName, error);
30
+ }
10
31
  function preserveRequestSignal(context, signal) {
11
32
  return context.signal === signal ? context : { ...context, signal };
12
33
  }
@@ -55,7 +76,7 @@ export async function runPluginBeforeRequest(plugins, context, logger, signal =
55
76
  if (result === undefined)
56
77
  continue;
57
78
  if (!isPluginResult(result)) {
58
- throw new Error(`Plugin ${plugin.name} didn't return valid result`);
79
+ throw invalidPluginResultError(plugin.name);
59
80
  }
60
81
  currentContext = preserveRequestSignal(result.context, signal);
61
82
  if (result.response !== undefined) {
@@ -79,7 +100,7 @@ export async function runPluginBeforeRequest(plugins, context, logger, signal =
79
100
  requestShortCircuited: true,
80
101
  };
81
102
  }
82
- throw new PluginError(plugin.name, recovery.error);
103
+ throw toPluginError(plugin.name, recovery.error);
83
104
  }
84
105
  }
85
106
  return { context: currentContext };
@@ -115,7 +136,7 @@ export async function runPluginPipeline(plugins, context, initialResponse, logge
115
136
  const result = await awaitWithAbort(plugin.process(currentContext, response), signal);
116
137
  throwIfAborted(signal);
117
138
  if (!isPluginResult(result)) {
118
- throw new Error(`Plugin ${plugin.name} didn't return valid result`);
139
+ throw invalidPluginResultError(plugin.name);
119
140
  }
120
141
  currentContext = preserveRequestSignal(result.context, signal);
121
142
  // First plugin to set response becomes the generator
@@ -140,7 +161,7 @@ export async function runPluginPipeline(plugins, context, initialResponse, logge
140
161
  recoveredFromError: true,
141
162
  };
142
163
  }
143
- throw new PluginError(plugin.name, recovery.error);
164
+ throw toPluginError(plugin.name, recovery.error);
144
165
  }
145
166
  }
146
167
  return { context: currentContext, response };