@maschinenlesbar.org/marktstammdatenregister-cli 0.1.0 → 0.2.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 +18 -8
- package/dist/src/cli/commands/units.js +13 -11
- package/dist/src/cli/index.js +3 -0
- package/dist/src/cli/io.d.ts +18 -0
- package/dist/src/cli/io.js +26 -0
- package/dist/src/cli/program.js +6 -6
- package/dist/src/cli/run.d.ts +10 -0
- package/dist/src/cli/run.js +27 -1
- package/dist/src/cli/shared.d.ts +13 -2
- package/dist/src/cli/shared.js +24 -5
- package/dist/src/client/client.d.ts +36 -6
- package/dist/src/client/client.js +124 -15
- package/dist/src/client/engine.d.ts +60 -10
- package/dist/src/client/engine.js +336 -33
- package/dist/src/client/errors.d.ts +15 -0
- package/dist/src/client/errors.js +51 -2
- package/dist/src/client/filter.d.ts +47 -2
- package/dist/src/client/filter.js +184 -4
- package/dist/src/client/http.d.ts +13 -0
- package/dist/src/client/http.js +16 -1
- package/dist/src/client/index.d.ts +4 -4
- package/dist/src/client/index.js +4 -4
- package/dist/src/client/types.d.ts +2 -1
- package/dist/src/client/validate.d.ts +4 -2
- package/dist/src/client/validate.js +14 -2
- package/package.json +3 -3
|
@@ -30,15 +30,17 @@ export interface EngineOptions {
|
|
|
30
30
|
*/
|
|
31
31
|
timeoutMs?: number;
|
|
32
32
|
/**
|
|
33
|
-
* Number of automatic retries for transient (429/503) responses
|
|
34
|
-
*
|
|
35
|
-
* (
|
|
36
|
-
* `
|
|
33
|
+
* Number of automatic retries for transient (429/503) responses and reset
|
|
34
|
+
* connections (`isTransientNetworkError`), an integer
|
|
35
|
+
* 0..`MAX_RETRIES` (10); defaults to 2. Each waits `retryDelayMs * attempt`, or the
|
|
36
|
+
* response's `Retry-After` when that is longer. A `Retry-After` above
|
|
37
|
+
* `MAX_RETRY_AFTER_MS` is not retried: the MastrApiError names the requested wait.
|
|
37
38
|
*/
|
|
38
39
|
maxRetries?: number;
|
|
39
40
|
/**
|
|
40
|
-
* Base backoff between retries in milliseconds (grows linearly),
|
|
41
|
-
* integer
|
|
41
|
+
* Base backoff between retries in milliseconds (grows linearly: `retryDelayMs * attempt`),
|
|
42
|
+
* an integer 0..`MAX_RETRY_AFTER_MS` (30 000). Defaults to 200. It is also the floor: a
|
|
43
|
+
* `Retry-After` can make a wait longer, never shorter.
|
|
42
44
|
*/
|
|
43
45
|
retryDelayMs?: number;
|
|
44
46
|
/**
|
|
@@ -96,13 +98,20 @@ export declare function isBidiControl(code: number): boolean;
|
|
|
96
98
|
* Written as a code-point filter so no raw control byte appears in this source.
|
|
97
99
|
*/
|
|
98
100
|
export declare function sanitizeServerText(text: string): string;
|
|
101
|
+
/**
|
|
102
|
+
* Longest server text (in characters) an error message keeps: a hostile or buggy body
|
|
103
|
+
* must not flood stderr or a CI log with one huge line. `MastrApiError.body` keeps the
|
|
104
|
+
* full text.
|
|
105
|
+
*/
|
|
106
|
+
export declare const MAX_DETAIL_LENGTH = 500;
|
|
99
107
|
/**
|
|
100
108
|
* Describe a Kendo `Errors` value for an error message: a string as is; otherwise
|
|
101
109
|
* (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
|
|
102
110
|
* every string found in it, sanitised, blanks and repeats dropped, joined "; ".
|
|
103
|
-
* Returns `undefined` when nothing readable is left.
|
|
111
|
+
* Returns `undefined` when nothing readable is left. `clean` runs on each raw string
|
|
112
|
+
* first (the engine passes its credential scrubber).
|
|
104
113
|
*/
|
|
105
|
-
export declare function describeMastrErrors(errors: unknown): string | undefined;
|
|
114
|
+
export declare function describeMastrErrors(errors: unknown, clean?: (text: string) => string): string | undefined;
|
|
106
115
|
/**
|
|
107
116
|
* Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
|
|
108
117
|
* or control characters, not an absolute URL, a scheme other than `http:`/`https:`,
|
|
@@ -119,23 +128,63 @@ export declare function validateBaseUrl(raw: string): string;
|
|
|
119
128
|
* `userAgent` and every `defaultHeaders` value before any request.
|
|
120
129
|
*/
|
|
121
130
|
export declare function assertHeaderValue(name: string, value: string): string;
|
|
131
|
+
/**
|
|
132
|
+
* True for a failure caused by a reset or aborted connection, which the engine retries —
|
|
133
|
+
* whichever transport raised it (a Node error, fetch's TypeError with an undici cause), the
|
|
134
|
+
* code anywhere in the `cause` chain. A refused connection, a DNS failure or a timeout is
|
|
135
|
+
* not transient in that sense and is not retried.
|
|
136
|
+
*/
|
|
137
|
+
export declare function isTransientNetworkError(err: unknown): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Throw for a key that is not an option name. A JavaScript caller's typo (`timeout` for
|
|
140
|
+
* `timeoutMs`) was ignored silently and the default applied; TypeScript catches it at
|
|
141
|
+
* compile time, JavaScript does not. `extra` names options a wrapper (the client) adds.
|
|
142
|
+
*/
|
|
143
|
+
export declare function assertKnownOptions(options: object, extra?: readonly string[]): void;
|
|
144
|
+
/** Engine options as given, or a MastrValidationError for a non-object (null counts as none). */
|
|
145
|
+
export declare function optionsObject<T extends object>(options: T | null | undefined): T;
|
|
122
146
|
export declare class RequestEngine {
|
|
123
|
-
private
|
|
147
|
+
#private;
|
|
124
148
|
private readonly transport;
|
|
125
149
|
private readonly userAgent;
|
|
126
|
-
private readonly defaultHeaders;
|
|
127
150
|
private readonly timeoutMs;
|
|
128
151
|
private readonly maxRetries;
|
|
129
152
|
private readonly retryDelayMs;
|
|
130
153
|
private readonly maxResponseBytes;
|
|
131
154
|
private readonly sleep;
|
|
132
155
|
constructor(options?: EngineOptions);
|
|
156
|
+
/**
|
|
157
|
+
* `text` without the base URL's credentials: server text (an error body that echoes the
|
|
158
|
+
* request URL) and transport text (fetch's "Failed to fetch <url>") can carry them. The
|
|
159
|
+
* client runs it on the `Errors` envelopes it turns into errors.
|
|
160
|
+
*/
|
|
161
|
+
scrub(text: string): string;
|
|
162
|
+
/**
|
|
163
|
+
* A transport failure as the `cause` of the error the engine raises: the original when its
|
|
164
|
+
* text carries no credentials, otherwise a copy with them scrubbed (message, `code` and the
|
|
165
|
+
* cause chain kept), so logging the error with its causes can't reveal the base URL's
|
|
166
|
+
* password.
|
|
167
|
+
*/
|
|
168
|
+
private scrubCause;
|
|
133
169
|
/** Build a fully-qualified URL from a path and optional query parameters. */
|
|
134
170
|
buildUrl(path: string, query?: QueryParams): string;
|
|
171
|
+
/**
|
|
172
|
+
* Call the transport under the overall deadline (`timeoutMs`): the request gets an
|
|
173
|
+
* AbortSignal that fires at the deadline, and the call rejects then whether the transport
|
|
174
|
+
* stops or not — a custom transport (fetch, a node:http wrapper) that ignores `timeoutMs`
|
|
175
|
+
* can't hang the caller. A synchronous throw becomes a rejection.
|
|
176
|
+
*/
|
|
177
|
+
private callTransport;
|
|
135
178
|
/**
|
|
136
179
|
* Perform a GET with Accept negotiation and transient-error retries. Redirects
|
|
137
180
|
* are NOT followed — the canonical host answers directly, so a 3xx (e.g. a bad
|
|
138
181
|
* base URL bouncing to a portal page) surfaces as an error.
|
|
182
|
+
*
|
|
183
|
+
* The engine enforces the transport contract itself, so it holds for a custom
|
|
184
|
+
* transport too: `timeoutMs` (an AbortSignal deadline), `maxResponseBytes` (checked on
|
|
185
|
+
* the body it gets back), any byte-array body, `Headers`/`Map`/any-case headers. Whatever
|
|
186
|
+
* a transport throws becomes a `MastrNetworkError`, and so does a malformed response; a
|
|
187
|
+
* reset connection (`isTransientNetworkError`) is retried like a 503.
|
|
139
188
|
*/
|
|
140
189
|
request(path: string, query?: QueryParams, accept?: string): Promise<RawResponse>;
|
|
141
190
|
/**
|
|
@@ -144,5 +193,6 @@ export declare class RequestEngine {
|
|
|
144
193
|
* `MastrParseError`, never a silent `null`.
|
|
145
194
|
*/
|
|
146
195
|
getJson<T>(path: string, query?: QueryParams): Promise<T>;
|
|
196
|
+
/** The MastrApiError for a non-2xx answer; `note` is appended to the detail. */
|
|
147
197
|
private toApiError;
|
|
148
198
|
}
|
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// a Transport, applies retry/backoff for transient statuses (429, 503), and decodes
|
|
3
3
|
// JSON responses. MaStR's public search backend is an unauthenticated GET API whose
|
|
4
4
|
// parameters travel in the query string.
|
|
5
|
-
import {
|
|
5
|
+
import { TextDecoder } from "node:util";
|
|
6
|
+
import { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage, } from "./http.js";
|
|
6
7
|
import { buildQueryString } from "./query.js";
|
|
7
|
-
import { MastrApiError, MastrParseError } from "./errors.js";
|
|
8
|
+
import { MastrApiError, MastrError, MastrNetworkError, MastrParseError, MastrValidationError, credentialsIn, redactCredentials, redactUrl, } from "./errors.js";
|
|
8
9
|
import { assertValid, baseUrlProblem, headerNameProblem, headerValueProblem, intRangeProblem, } from "./validate.js";
|
|
9
10
|
export const DEFAULT_BASE_URL = "https://www.marktstammdatenregister.de/MaStR";
|
|
10
11
|
const DEFAULT_USER_AGENT = "marktstammdatenregister-cli";
|
|
@@ -90,17 +91,28 @@ export function sanitizeServerText(text) {
|
|
|
90
91
|
}
|
|
91
92
|
return out.replace(/\s+/g, " ").trim();
|
|
92
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* Longest server text (in characters) an error message keeps: a hostile or buggy body
|
|
96
|
+
* must not flood stderr or a CI log with one huge line. `MastrApiError.body` keeps the
|
|
97
|
+
* full text.
|
|
98
|
+
*/
|
|
99
|
+
export const MAX_DETAIL_LENGTH = 500;
|
|
100
|
+
/** `text` cut at MAX_DETAIL_LENGTH characters, ending in "…" when cut. */
|
|
101
|
+
function cutDetail(text) {
|
|
102
|
+
return text.length > MAX_DETAIL_LENGTH ? `${text.slice(0, MAX_DETAIL_LENGTH)}…` : text;
|
|
103
|
+
}
|
|
93
104
|
/**
|
|
94
105
|
* Describe a Kendo `Errors` value for an error message: a string as is; otherwise
|
|
95
106
|
* (a ModelState object such as `{"": {"errors": ["Invalid filter"]}}`, or an array)
|
|
96
107
|
* every string found in it, sanitised, blanks and repeats dropped, joined "; ".
|
|
97
|
-
* Returns `undefined` when nothing readable is left.
|
|
108
|
+
* Returns `undefined` when nothing readable is left. `clean` runs on each raw string
|
|
109
|
+
* first (the engine passes its credential scrubber).
|
|
98
110
|
*/
|
|
99
|
-
export function describeMastrErrors(errors) {
|
|
111
|
+
export function describeMastrErrors(errors, clean = (text) => text) {
|
|
100
112
|
const found = [];
|
|
101
113
|
const walk = (value, depth) => {
|
|
102
114
|
if (typeof value === "string") {
|
|
103
|
-
const text = sanitizeServerText(value);
|
|
115
|
+
const text = sanitizeServerText(clean(value));
|
|
104
116
|
if (text !== "" && !found.includes(text))
|
|
105
117
|
found.push(text);
|
|
106
118
|
}
|
|
@@ -110,7 +122,7 @@ export function describeMastrErrors(errors) {
|
|
|
110
122
|
}
|
|
111
123
|
};
|
|
112
124
|
walk(errors, 0);
|
|
113
|
-
return found.length > 0 ? found.join("; ") : undefined;
|
|
125
|
+
return found.length > 0 ? cutDetail(found.join("; ")) : undefined;
|
|
114
126
|
}
|
|
115
127
|
/**
|
|
116
128
|
* Check a base URL against every rule of {@link baseUrlProblem} — blank, whitespace
|
|
@@ -134,6 +146,9 @@ export function assertHeaderValue(name, value) {
|
|
|
134
146
|
}
|
|
135
147
|
/** Check every `defaultHeaders` name (a token) and value; returns a copy. */
|
|
136
148
|
function checkedHeaders(headers) {
|
|
149
|
+
if (typeof headers !== "object" || headers === null || Array.isArray(headers)) {
|
|
150
|
+
throw new MastrValidationError(`Invalid defaultHeaders: expected an object of header names and values, got ${describeType(headers)}.`);
|
|
151
|
+
}
|
|
137
152
|
const out = {};
|
|
138
153
|
for (const [name, value] of Object.entries(headers)) {
|
|
139
154
|
assertValid("defaultHeaders name", name, headerNameProblem);
|
|
@@ -141,53 +156,271 @@ function checkedHeaders(headers) {
|
|
|
141
156
|
}
|
|
142
157
|
return out;
|
|
143
158
|
}
|
|
159
|
+
/** Why `value` is not a usable HttpResponse, or undefined when it is. */
|
|
160
|
+
function responseProblem(value) {
|
|
161
|
+
if (typeof value !== "object" || value === null)
|
|
162
|
+
return "not an object";
|
|
163
|
+
const r = value;
|
|
164
|
+
if (typeof r.status !== "number" || !Number.isInteger(r.status) || r.status < 100 || r.status > 599) {
|
|
165
|
+
return "status is not an HTTP status code";
|
|
166
|
+
}
|
|
167
|
+
if (typeof r.headers !== "object" || r.headers === null || Array.isArray(r.headers))
|
|
168
|
+
return "headers is not an object";
|
|
169
|
+
if (bodyBytes(r.body) === undefined)
|
|
170
|
+
return "body is not a Buffer, Uint8Array, other ArrayBuffer view or ArrayBuffer";
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The response body as a Buffer (a view, no copy): a Buffer, any ArrayBuffer view (a
|
|
175
|
+
* Uint8Array from fetch, a DataView) or an ArrayBuffer/SharedArrayBuffer — checked by internal
|
|
176
|
+
* slot, not `instanceof`, so a value from another realm (a vm context, a Jest test) counts.
|
|
177
|
+
* Undefined for anything else.
|
|
178
|
+
*/
|
|
179
|
+
function bodyBytes(value) {
|
|
180
|
+
if (Buffer.isBuffer(value))
|
|
181
|
+
return value;
|
|
182
|
+
if (ArrayBuffer.isView(value))
|
|
183
|
+
return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
|
|
184
|
+
const tag = Object.prototype.toString.call(value);
|
|
185
|
+
if (tag === "[object ArrayBuffer]" || tag === "[object SharedArrayBuffer]")
|
|
186
|
+
return Buffer.from(value);
|
|
187
|
+
return undefined;
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* The response headers as a plain record with lower-case names. A transport built on
|
|
191
|
+
* `fetch` returns its `Headers` object, which has no plain properties (the engine then saw
|
|
192
|
+
* no Retry-After and no Content-Type at all); such an object, or a `Map` (anything with
|
|
193
|
+
* `get` and `forEach`), is copied into a record. A custom transport may also not
|
|
194
|
+
* lower-case the names ("Retry-After").
|
|
195
|
+
*/
|
|
196
|
+
function plainHeaders(headers) {
|
|
197
|
+
const h = headers;
|
|
198
|
+
const record = {};
|
|
199
|
+
if (typeof h.get === "function" && typeof h.forEach === "function") {
|
|
200
|
+
h.forEach.call(headers, (value, name) => {
|
|
201
|
+
record[String(name).toLowerCase()] = value;
|
|
202
|
+
});
|
|
203
|
+
return record;
|
|
204
|
+
}
|
|
205
|
+
for (const [name, value] of Object.entries(headers)) {
|
|
206
|
+
record[name.toLowerCase()] = value;
|
|
207
|
+
}
|
|
208
|
+
return record;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Error codes of a connection that broke off mid-request: Node's (`socket hang up` is
|
|
212
|
+
* ECONNRESET) and undici's (`fetch failed` with cause UND_ERR_SOCKET, "other side closed").
|
|
213
|
+
*/
|
|
214
|
+
const TRANSIENT_NETWORK_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "UND_ERR_SOCKET"]);
|
|
215
|
+
/** True when `err` or an error in its `cause` chain has a transient connection code. */
|
|
216
|
+
function hasTransientCode(err, depth = 0) {
|
|
217
|
+
if (typeof err !== "object" || err === null || depth > 4)
|
|
218
|
+
return false;
|
|
219
|
+
const code = err.code;
|
|
220
|
+
if (typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code))
|
|
221
|
+
return true;
|
|
222
|
+
return hasTransientCode(err.cause, depth + 1);
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* True for a failure caused by a reset or aborted connection, which the engine retries —
|
|
226
|
+
* whichever transport raised it (a Node error, fetch's TypeError with an undici cause), the
|
|
227
|
+
* code anywhere in the `cause` chain. A refused connection, a DNS failure or a timeout is
|
|
228
|
+
* not transient in that sense and is not retried.
|
|
229
|
+
*/
|
|
230
|
+
export function isTransientNetworkError(err) {
|
|
231
|
+
return hasTransientCode(err);
|
|
232
|
+
}
|
|
233
|
+
/** A value's type for a validation message: "null", "an array", "a string", … */
|
|
234
|
+
function describeType(value) {
|
|
235
|
+
if (value === null)
|
|
236
|
+
return "null";
|
|
237
|
+
if (Array.isArray(value))
|
|
238
|
+
return "an array";
|
|
239
|
+
return `a ${typeof value}`;
|
|
240
|
+
}
|
|
241
|
+
/** Every EngineOptions key. */
|
|
242
|
+
const OPTION_NAMES = [
|
|
243
|
+
"baseUrl",
|
|
244
|
+
"transport",
|
|
245
|
+
"userAgent",
|
|
246
|
+
"defaultHeaders",
|
|
247
|
+
"timeoutMs",
|
|
248
|
+
"maxRetries",
|
|
249
|
+
"retryDelayMs",
|
|
250
|
+
"maxResponseBytes",
|
|
251
|
+
"sleep",
|
|
252
|
+
];
|
|
253
|
+
/**
|
|
254
|
+
* Throw for a key that is not an option name. A JavaScript caller's typo (`timeout` for
|
|
255
|
+
* `timeoutMs`) was ignored silently and the default applied; TypeScript catches it at
|
|
256
|
+
* compile time, JavaScript does not. `extra` names options a wrapper (the client) adds.
|
|
257
|
+
*/
|
|
258
|
+
export function assertKnownOptions(options, extra = []) {
|
|
259
|
+
const names = [...OPTION_NAMES, ...extra];
|
|
260
|
+
for (const [key, value] of Object.entries(options)) {
|
|
261
|
+
// An unset key (`proxy: undefined` from a spread config) changes nothing: skip it.
|
|
262
|
+
if (value === undefined || names.includes(key))
|
|
263
|
+
continue;
|
|
264
|
+
const lower = key.toLowerCase();
|
|
265
|
+
const hint = names.find((name) => name.toLowerCase().includes(lower) || lower.includes(name.toLowerCase()));
|
|
266
|
+
throw new MastrValidationError(`Unknown option ${JSON.stringify(key)}` +
|
|
267
|
+
(hint === undefined ? `; the options are ${names.join(", ")}.` : ` (did you mean ${hint}?).`));
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Read a function option: `undefined` gives the default; anything else that is not a
|
|
272
|
+
* function throws. A string `transport` used to fail at the first request, and a bad
|
|
273
|
+
* `sleep` as a raw TypeError on the first retry.
|
|
274
|
+
*/
|
|
275
|
+
function functionOption(name, value, fallback) {
|
|
276
|
+
if (value === undefined)
|
|
277
|
+
return fallback;
|
|
278
|
+
if (typeof value !== "function") {
|
|
279
|
+
throw new MastrValidationError(`Invalid ${name}: expected a function, got ${describeType(value)}.`);
|
|
280
|
+
}
|
|
281
|
+
return value;
|
|
282
|
+
}
|
|
283
|
+
/** Engine options as given, or a MastrValidationError for a non-object (null counts as none). */
|
|
284
|
+
export function optionsObject(options) {
|
|
285
|
+
if (options === undefined || options === null)
|
|
286
|
+
return {};
|
|
287
|
+
if (typeof options !== "object" || Array.isArray(options)) {
|
|
288
|
+
throw new MastrValidationError(`Invalid options: expected an object, got ${describeType(options)}.`);
|
|
289
|
+
}
|
|
290
|
+
return options;
|
|
291
|
+
}
|
|
144
292
|
const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
145
293
|
export class RequestEngine {
|
|
146
|
-
|
|
294
|
+
// Real private fields (not TypeScript's `private`): util.inspect, console.log and
|
|
295
|
+
// JSON.stringify of a client never show them, so a password in the base URL (or a
|
|
296
|
+
// credential in a default header) can't be logged by accident.
|
|
297
|
+
#baseUrl;
|
|
298
|
+
/** The base URL's userinfo, raw and percent-decoded, for scrubbing server and transport text. */
|
|
299
|
+
#credentials;
|
|
147
300
|
transport;
|
|
148
301
|
userAgent;
|
|
149
|
-
defaultHeaders;
|
|
302
|
+
#defaultHeaders;
|
|
150
303
|
timeoutMs;
|
|
151
304
|
maxRetries;
|
|
152
305
|
retryDelayMs;
|
|
153
306
|
maxResponseBytes;
|
|
154
307
|
sleep;
|
|
155
308
|
constructor(options = {}) {
|
|
309
|
+
// A JavaScript caller may pass null for "no options"; anything else must be an object
|
|
310
|
+
// with known keys.
|
|
311
|
+
options = optionsObject(options);
|
|
312
|
+
assertKnownOptions(options);
|
|
156
313
|
// The raw value is checked before the trailing-slash strip, so "https://h/ "
|
|
157
314
|
// cannot slip past it; only an omitted baseUrl selects the default.
|
|
158
|
-
|
|
159
|
-
this
|
|
315
|
+
const baseUrl = options.baseUrl === undefined ? DEFAULT_BASE_URL : options.baseUrl;
|
|
316
|
+
this.#baseUrl = validateBaseUrl(baseUrl);
|
|
317
|
+
this.#credentials = credentialsIn(baseUrl).flatMap((raw) => {
|
|
318
|
+
try {
|
|
319
|
+
return [raw, decodeURIComponent(raw)];
|
|
320
|
+
}
|
|
321
|
+
catch {
|
|
322
|
+
return [raw];
|
|
323
|
+
}
|
|
324
|
+
});
|
|
325
|
+
this.transport = functionOption("transport", options.transport, nodeHttpTransport);
|
|
160
326
|
// Header values are checked up front: a blank one would be sent as is, and a
|
|
161
327
|
// CR/LF or a character above U+00FF would reach a custom transport raw or make
|
|
162
328
|
// Node's HTTP layer throw an untyped ERR_INVALID_CHAR. Only an omitted
|
|
163
329
|
// userAgent selects the default.
|
|
164
330
|
this.userAgent =
|
|
165
331
|
options.userAgent === undefined ? DEFAULT_USER_AGENT : assertHeaderValue("userAgent", options.userAgent);
|
|
166
|
-
this
|
|
332
|
+
this.#defaultHeaders = checkedHeaders(options.defaultHeaders ?? {});
|
|
167
333
|
// Range-check the numeric options: a negative, NaN or fractional value would
|
|
168
334
|
// otherwise silently disable the timeout or the size cap, and an unbounded
|
|
169
335
|
// maxRetries would keep retrying against the production register.
|
|
170
336
|
this.timeoutMs = intOption("timeoutMs", options.timeoutMs, MAX_TIMEOUT_MS, 30_000);
|
|
171
337
|
this.maxRetries = intOption("maxRetries", options.maxRetries, MAX_RETRIES, 2);
|
|
172
|
-
|
|
338
|
+
// Bounded like a Retry-After wait: a larger value would stall the CLI, and one above
|
|
339
|
+
// 2^31 - 1 ms would overflow Node's timer and retry after 1 ms.
|
|
340
|
+
this.retryDelayMs = intOption("retryDelayMs", options.retryDelayMs, MAX_RETRY_AFTER_MS, 200);
|
|
173
341
|
this.maxResponseBytes = intOption("maxResponseBytes", options.maxResponseBytes, Number.MAX_SAFE_INTEGER, DEFAULT_MAX_RESPONSE_BYTES);
|
|
174
|
-
this.sleep = options.sleep
|
|
342
|
+
this.sleep = functionOption("sleep", options.sleep, realSleep);
|
|
343
|
+
}
|
|
344
|
+
/**
|
|
345
|
+
* `text` without the base URL's credentials: server text (an error body that echoes the
|
|
346
|
+
* request URL) and transport text (fetch's "Failed to fetch <url>") can carry them. The
|
|
347
|
+
* client runs it on the `Errors` envelopes it turns into errors.
|
|
348
|
+
*/
|
|
349
|
+
scrub(text) {
|
|
350
|
+
return this.#credentials.length === 0 ? text : redactCredentials(text, this.#credentials);
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* A transport failure as the `cause` of the error the engine raises: the original when its
|
|
354
|
+
* text carries no credentials, otherwise a copy with them scrubbed (message, `code` and the
|
|
355
|
+
* cause chain kept), so logging the error with its causes can't reveal the base URL's
|
|
356
|
+
* password.
|
|
357
|
+
*/
|
|
358
|
+
scrubCause(cause, depth = 0) {
|
|
359
|
+
if (this.#credentials.length === 0 || depth > 5)
|
|
360
|
+
return cause;
|
|
361
|
+
if (typeof cause === "string")
|
|
362
|
+
return this.scrub(cause);
|
|
363
|
+
if (!(cause instanceof Error))
|
|
364
|
+
return cause;
|
|
365
|
+
const inner = this.scrubCause(cause.cause, depth + 1);
|
|
366
|
+
const message = this.scrub(cause.message);
|
|
367
|
+
if (message === cause.message && inner === cause.cause && !this.scrub(cause.stack ?? "").includes("***@")) {
|
|
368
|
+
return cause;
|
|
369
|
+
}
|
|
370
|
+
const copy = new Error(message, inner === undefined ? undefined : { cause: inner });
|
|
371
|
+
copy.name = cause.name;
|
|
372
|
+
const code = cause.code;
|
|
373
|
+
if (code !== undefined)
|
|
374
|
+
Object.assign(copy, { code });
|
|
375
|
+
return copy;
|
|
175
376
|
}
|
|
176
377
|
/** Build a fully-qualified URL from a path and optional query parameters. */
|
|
177
378
|
buildUrl(path, query) {
|
|
178
379
|
const normalizedPath = path.startsWith("/") ? path : `/${path}`;
|
|
179
380
|
const qs = query ? buildQueryString(query) : "";
|
|
180
|
-
return `${this
|
|
381
|
+
return `${this.#baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
|
|
382
|
+
}
|
|
383
|
+
/**
|
|
384
|
+
* Call the transport under the overall deadline (`timeoutMs`): the request gets an
|
|
385
|
+
* AbortSignal that fires at the deadline, and the call rejects then whether the transport
|
|
386
|
+
* stops or not — a custom transport (fetch, a node:http wrapper) that ignores `timeoutMs`
|
|
387
|
+
* can't hang the caller. A synchronous throw becomes a rejection.
|
|
388
|
+
*/
|
|
389
|
+
async callTransport(request) {
|
|
390
|
+
const call = (signal) => Promise.resolve().then(() => this.transport(signal === undefined ? request : { ...request, signal }));
|
|
391
|
+
if (this.timeoutMs === 0)
|
|
392
|
+
return call();
|
|
393
|
+
const controller = new AbortController();
|
|
394
|
+
let timer;
|
|
395
|
+
const deadline = new Promise((_, reject) => {
|
|
396
|
+
timer = setTimeout(() => {
|
|
397
|
+
const err = new MastrNetworkError(`Request timed out after ${this.timeoutMs}ms`);
|
|
398
|
+
controller.abort(err);
|
|
399
|
+
reject(err);
|
|
400
|
+
}, this.timeoutMs);
|
|
401
|
+
});
|
|
402
|
+
try {
|
|
403
|
+
return await Promise.race([call(controller.signal), deadline]);
|
|
404
|
+
}
|
|
405
|
+
finally {
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
}
|
|
181
408
|
}
|
|
182
409
|
/**
|
|
183
410
|
* Perform a GET with Accept negotiation and transient-error retries. Redirects
|
|
184
411
|
* are NOT followed — the canonical host answers directly, so a 3xx (e.g. a bad
|
|
185
412
|
* base URL bouncing to a portal page) surfaces as an error.
|
|
413
|
+
*
|
|
414
|
+
* The engine enforces the transport contract itself, so it holds for a custom
|
|
415
|
+
* transport too: `timeoutMs` (an AbortSignal deadline), `maxResponseBytes` (checked on
|
|
416
|
+
* the body it gets back), any byte-array body, `Headers`/`Map`/any-case headers. Whatever
|
|
417
|
+
* a transport throws becomes a `MastrNetworkError`, and so does a malformed response; a
|
|
418
|
+
* reset connection (`isTransientNetworkError`) is retried like a 503.
|
|
186
419
|
*/
|
|
187
420
|
async request(path, query, accept = "application/json") {
|
|
188
421
|
const url = this.buildUrl(path, query);
|
|
189
422
|
const headers = {
|
|
190
|
-
...this
|
|
423
|
+
...this.#defaultHeaders,
|
|
191
424
|
Accept: accept,
|
|
192
425
|
"User-Agent": this.userAgent,
|
|
193
426
|
// The MaStR search backend is a Kendo/DataTables endpoint that expects an
|
|
@@ -196,30 +429,80 @@ export class RequestEngine {
|
|
|
196
429
|
};
|
|
197
430
|
let attempt = 0;
|
|
198
431
|
for (;;) {
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
432
|
+
let response;
|
|
433
|
+
try {
|
|
434
|
+
response = await this.callTransport({
|
|
435
|
+
method: "GET",
|
|
436
|
+
url,
|
|
437
|
+
headers,
|
|
438
|
+
timeoutMs: this.timeoutMs,
|
|
439
|
+
...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
catch (cause) {
|
|
443
|
+
// A connection the server (or a gateway) reset is the network-level twin of a 503:
|
|
444
|
+
// retry it, whichever transport reported it. Timeouts are not retried.
|
|
445
|
+
if (hasTransientCode(cause) && attempt < this.maxRetries) {
|
|
446
|
+
attempt += 1;
|
|
447
|
+
await this.sleep(this.retryDelayMs * attempt);
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
// The default transport rejects with MastrNetworkError only; an injected one may
|
|
451
|
+
// throw anything (fetch's TypeError, a string, null). Keep the library's contract:
|
|
452
|
+
// every failure is a MastrError.
|
|
453
|
+
if (cause instanceof MastrNetworkError) {
|
|
454
|
+
// Its text may echo the request URL; re-raise it scrubbed when it does.
|
|
455
|
+
const message = this.scrub(cause.message);
|
|
456
|
+
const inner = this.scrubCause(cause.cause);
|
|
457
|
+
if (message === cause.message && inner === cause.cause)
|
|
458
|
+
throw cause;
|
|
459
|
+
throw new MastrNetworkError(message, inner === undefined ? undefined : { cause: inner });
|
|
460
|
+
}
|
|
461
|
+
if (cause instanceof MastrError)
|
|
462
|
+
throw cause;
|
|
463
|
+
const reason = cause instanceof Error ? cause.message : String(cause);
|
|
464
|
+
throw new MastrNetworkError(`GET ${redactUrl(url)} failed: ${sanitizeServerText(this.scrub(reason))}`, {
|
|
465
|
+
cause: this.scrubCause(cause),
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
// An injected transport may resolve with anything; a malformed HttpResponse would
|
|
469
|
+
// otherwise surface as a raw TypeError, or a missing status as a success.
|
|
470
|
+
const invalid = responseProblem(response);
|
|
471
|
+
if (invalid !== undefined) {
|
|
472
|
+
throw new MastrNetworkError(`GET ${redactUrl(url)} failed: the transport returned an invalid response (${invalid}).`);
|
|
473
|
+
}
|
|
206
474
|
const status = response.status;
|
|
475
|
+
const responseHeaders = plainHeaders(response.headers);
|
|
476
|
+
const body = bodyBytes(response.body);
|
|
477
|
+
// The size cap holds whatever the transport did: the default one aborts early, a
|
|
478
|
+
// custom one may have read everything.
|
|
479
|
+
if (this.maxResponseBytes > 0 && body.byteLength > this.maxResponseBytes) {
|
|
480
|
+
throw new MastrNetworkError(sizeLimitMessage(this.maxResponseBytes));
|
|
481
|
+
}
|
|
207
482
|
const retryable = status === 429 || status === 503;
|
|
483
|
+
// A Retry-After beyond MAX_RETRY_AFTER_MS is not retried: retrying early would land
|
|
484
|
+
// inside the window the server asked us to wait out, and a hostile value must not stall
|
|
485
|
+
// the CLI. The error then names the requested wait, so a script knows when to try again.
|
|
486
|
+
const retryAfter = retryable ? parseRetryAfter(responseHeaders["retry-after"]) : undefined;
|
|
208
487
|
if (retryable && attempt < this.maxRetries) {
|
|
209
|
-
// Honour Retry-After; without a usable one, back off linearly. A Retry-After
|
|
210
|
-
// beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces at once.
|
|
211
|
-
const retryAfter = parseRetryAfter(response.headers["retry-after"]);
|
|
212
488
|
if (retryAfter === undefined || retryAfter <= MAX_RETRY_AFTER_MS) {
|
|
213
489
|
attempt += 1;
|
|
214
|
-
|
|
490
|
+
// The linear backoff is the floor: a Retry-After can make a wait longer, never
|
|
491
|
+
// shorter. `Retry-After: 0` or a date in the past turned the retries into a
|
|
492
|
+
// zero-delay burst against a register that had just answered 429/503.
|
|
493
|
+
const backoff = this.retryDelayMs * attempt;
|
|
494
|
+
await this.sleep(retryAfter === undefined ? backoff : Math.max(retryAfter, backoff));
|
|
215
495
|
continue;
|
|
216
496
|
}
|
|
497
|
+
throw this.toApiError(url, status, body, `the server asked to wait ${Math.ceil(retryAfter / 1000)} s (Retry-After) before trying again, ` +
|
|
498
|
+
`longer than the ${MAX_RETRY_AFTER_MS / 1000} s the client waits, so it was not retried; ` +
|
|
499
|
+
"retrying sooner won't help");
|
|
217
500
|
}
|
|
218
|
-
const contentType = String(
|
|
501
|
+
const contentType = String(responseHeaders["content-type"] ?? "");
|
|
219
502
|
if (status < 200 || status >= 300) {
|
|
220
|
-
throw this.toApiError(url, status,
|
|
503
|
+
throw this.toApiError(url, status, body);
|
|
221
504
|
}
|
|
222
|
-
return { data:
|
|
505
|
+
return { data: body, contentType, status };
|
|
223
506
|
}
|
|
224
507
|
}
|
|
225
508
|
/**
|
|
@@ -229,7 +512,7 @@ export class RequestEngine {
|
|
|
229
512
|
*/
|
|
230
513
|
async getJson(path, query) {
|
|
231
514
|
const res = await this.request(path, query);
|
|
232
|
-
const text = res.data.
|
|
515
|
+
const text = decodeBody(res.data, res.contentType, path);
|
|
233
516
|
if (res.status === 204 || text.trim().length === 0) {
|
|
234
517
|
throw new MastrParseError(`Empty response body from ${path}`);
|
|
235
518
|
}
|
|
@@ -240,8 +523,9 @@ export class RequestEngine {
|
|
|
240
523
|
throw new MastrParseError(`Failed to parse JSON response from ${path}`, { cause });
|
|
241
524
|
}
|
|
242
525
|
}
|
|
243
|
-
|
|
244
|
-
|
|
526
|
+
/** The MastrApiError for a non-2xx answer; `note` is appended to the detail. */
|
|
527
|
+
toApiError(url, status, body, note) {
|
|
528
|
+
const text = this.scrub(body.toString("utf8"));
|
|
245
529
|
let detail;
|
|
246
530
|
try {
|
|
247
531
|
const parsed = JSON.parse(text);
|
|
@@ -264,7 +548,26 @@ export class RequestEngine {
|
|
|
264
548
|
// collapse above does not remove ESC, so strip control characters before it can
|
|
265
549
|
// reach stderr and inject terminal escape sequences.
|
|
266
550
|
if (detail !== undefined)
|
|
267
|
-
detail = sanitizeServerText(detail);
|
|
551
|
+
detail = cutDetail(sanitizeServerText(detail));
|
|
552
|
+
if (note !== undefined)
|
|
553
|
+
detail = detail === undefined || detail === "" ? note : `${detail}; ${note}`;
|
|
268
554
|
return new MastrApiError({ status, url, method: "GET", body: text, detail });
|
|
269
555
|
}
|
|
270
556
|
}
|
|
557
|
+
/**
|
|
558
|
+
* Decode a response body by the charset its Content-Type names (UTF-8 when it names none):
|
|
559
|
+
* a Latin-1 body from a re-encoding proxy or mirror became U+FFFD with `toString("utf8")`.
|
|
560
|
+
* TextDecoder also drops a leading byte order mark, which JSON.parse would reject. An
|
|
561
|
+
* unknown charset label is a MastrParseError.
|
|
562
|
+
*/
|
|
563
|
+
function decodeBody(body, contentType, path) {
|
|
564
|
+
const charset = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType)?.[1] ?? "utf-8";
|
|
565
|
+
let decoder;
|
|
566
|
+
try {
|
|
567
|
+
decoder = new TextDecoder(charset);
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
throw new MastrParseError(`Unsupported response charset "${sanitizeServerText(charset)}" from ${path}.`);
|
|
571
|
+
}
|
|
572
|
+
return decoder.decode(body);
|
|
573
|
+
}
|
|
@@ -4,6 +4,21 @@
|
|
|
4
4
|
* A URL without userinfo, or one that does not parse, is returned unchanged.
|
|
5
5
|
*/
|
|
6
6
|
export declare function redactUrl(url: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* The userinfo a URL-like value carries, exactly as written — `["alice:pa#ss"]` for
|
|
9
|
+
* `https://alice:pa#ss@host` — or `[]` when it carries none. It works on values that don't
|
|
10
|
+
* parse as a URL too, and on values with a prefix (`--base-url=https://u:p@h`): the userinfo
|
|
11
|
+
* is everything between `://` and the last `@` before the host. A value without a scheme
|
|
12
|
+
* counts when it reads `user:password@host`. Used to redact those exact strings from text
|
|
13
|
+
* that echoes the value (usage errors, help), whatever characters the password contains.
|
|
14
|
+
*/
|
|
15
|
+
export declare function credentialsIn(value: string): string[];
|
|
16
|
+
/**
|
|
17
|
+
* `text` with every occurrence of each credential (as `credentialsIn` returns them) that is
|
|
18
|
+
* followed by `@` replaced by `***`. Matching the exact strings, not a pattern, covers
|
|
19
|
+
* passwords with spaces, quotes, `#`, `?` or `/` that no URL pattern can delimit.
|
|
20
|
+
*/
|
|
21
|
+
export declare function redactCredentials(text: string, credentials: readonly string[]): string;
|
|
7
22
|
/** Base class for every error originating from this client. */
|
|
8
23
|
export declare class MastrError extends Error {
|
|
9
24
|
constructor(message: string, options?: {
|