@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.
- package/README.md +129 -0
- package/dist/abort.d.ts +11 -1
- package/dist/abort.js +13 -2
- package/dist/adapter.d.ts +19 -0
- package/dist/adapter.js +17 -0
- package/dist/admission.d.ts +21 -0
- package/dist/admission.js +39 -0
- package/dist/binary.d.ts +0 -1
- package/dist/builder.d.ts +16 -32
- package/dist/builder.js +397 -903
- package/dist/constants.d.ts +33 -2
- package/dist/constants.js +73 -1
- package/dist/debug-logger.d.ts +10 -0
- package/dist/debug-logger.js +31 -0
- package/dist/delay.d.ts +12 -0
- package/dist/delay.js +37 -0
- package/dist/errors.d.ts +13 -2
- package/dist/errors.js +21 -2
- package/dist/events.d.ts +17 -0
- package/dist/events.js +58 -0
- package/dist/generations.d.ts +43 -0
- package/dist/generations.js +75 -0
- package/dist/headers.d.ts +27 -0
- package/dist/headers.js +57 -0
- package/dist/helpers.d.ts +9 -10
- package/dist/helpers.js +4 -1
- package/dist/history.d.ts +56 -0
- package/dist/history.js +230 -0
- package/dist/http-helpers.d.ts +110 -5
- package/dist/http-helpers.js +328 -46
- package/dist/index.d.ts +213 -31
- package/dist/index.js +17 -9
- package/dist/interceptor.d.ts +15 -11
- package/dist/interceptor.js +241 -164
- package/dist/node-server.d.ts +27 -0
- package/dist/node-server.js +166 -0
- package/dist/parser.d.ts +0 -1
- package/dist/parser.js +145 -22
- package/dist/plugin-hooks.d.ts +40 -0
- package/dist/plugin-hooks.js +192 -0
- package/dist/plugin-pipeline.d.ts +0 -1
- package/dist/plugin-pipeline.js +25 -4
- package/dist/response-normalizer.d.ts +36 -1
- package/dist/response-normalizer.js +102 -0
- package/dist/response-parser.d.ts +19 -1
- package/dist/response-parser.js +77 -19
- package/dist/route-matcher.d.ts +0 -1
- package/dist/route-table.d.ts +64 -0
- package/dist/route-table.js +220 -0
- package/dist/types.d.ts +27 -1
- package/package.json +8 -3
- package/dist/abort.d.ts.map +0 -1
- package/dist/binary.d.ts.map +0 -1
- package/dist/builder.d.ts.map +0 -1
- package/dist/constants.d.ts.map +0 -1
- package/dist/errors.d.ts.map +0 -1
- package/dist/helpers.d.ts.map +0 -1
- package/dist/http-helpers.d.ts.map +0 -1
- package/dist/index.d.ts.map +0 -1
- package/dist/interceptor.d.ts.map +0 -1
- package/dist/parser.d.ts.map +0 -1
- package/dist/plugin-pipeline.d.ts.map +0 -1
- package/dist/response-normalizer.d.ts.map +0 -1
- package/dist/response-parser.d.ts.map +0 -1
- package/dist/route-matcher.d.ts.map +0 -1
- package/dist/types.d.ts.map +0 -1
|
@@ -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
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
|
-
//
|
|
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
|
|
29
|
-
|
|
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
|
package/dist/plugin-pipeline.js
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
164
|
+
throw toPluginError(plugin.name, recovery.error);
|
|
144
165
|
}
|
|
145
166
|
}
|
|
146
167
|
return { context: currentContext, response };
|