@maschinenlesbar.org/pegel-online-cli 0.1.0 → 0.3.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 (64) hide show
  1. package/README.md +29 -8
  2. package/dist/src/cli/commands/stations.d.ts +0 -1
  3. package/dist/src/cli/commands/stations.js +29 -5
  4. package/dist/src/cli/commands/timeseries.d.ts +0 -1
  5. package/dist/src/cli/commands/timeseries.js +3 -4
  6. package/dist/src/cli/index.d.ts +0 -1
  7. package/dist/src/cli/index.js +0 -1
  8. package/dist/src/cli/io.d.ts +2 -2
  9. package/dist/src/cli/io.js +7 -3
  10. package/dist/src/cli/program.d.ts +0 -1
  11. package/dist/src/cli/program.js +6 -7
  12. package/dist/src/cli/run.d.ts +12 -1
  13. package/dist/src/cli/run.js +35 -2
  14. package/dist/src/cli/shared.d.ts +21 -15
  15. package/dist/src/cli/shared.js +39 -48
  16. package/dist/src/client/client.d.ts +46 -1
  17. package/dist/src/client/client.js +152 -27
  18. package/dist/src/client/engine.d.ts +51 -13
  19. package/dist/src/client/engine.js +404 -81
  20. package/dist/src/client/errors.d.ts +38 -2
  21. package/dist/src/client/errors.js +74 -4
  22. package/dist/src/client/http.d.ts +26 -1
  23. package/dist/src/client/http.js +16 -2
  24. package/dist/src/client/index.d.ts +6 -4
  25. package/dist/src/client/index.js +4 -4
  26. package/dist/src/client/query.d.ts +0 -1
  27. package/dist/src/client/query.js +0 -1
  28. package/dist/src/client/types.d.ts +60 -10
  29. package/dist/src/client/types.js +0 -1
  30. package/dist/src/client/validate.d.ts +72 -0
  31. package/dist/src/client/validate.js +155 -0
  32. package/dist/src/index.d.ts +0 -1
  33. package/dist/src/index.js +0 -1
  34. package/package.json +4 -3
  35. package/dist/src/cli/commands/stations.d.ts.map +0 -1
  36. package/dist/src/cli/commands/stations.js.map +0 -1
  37. package/dist/src/cli/commands/timeseries.d.ts.map +0 -1
  38. package/dist/src/cli/commands/timeseries.js.map +0 -1
  39. package/dist/src/cli/index.d.ts.map +0 -1
  40. package/dist/src/cli/index.js.map +0 -1
  41. package/dist/src/cli/io.d.ts.map +0 -1
  42. package/dist/src/cli/io.js.map +0 -1
  43. package/dist/src/cli/program.d.ts.map +0 -1
  44. package/dist/src/cli/program.js.map +0 -1
  45. package/dist/src/cli/run.d.ts.map +0 -1
  46. package/dist/src/cli/run.js.map +0 -1
  47. package/dist/src/cli/shared.d.ts.map +0 -1
  48. package/dist/src/cli/shared.js.map +0 -1
  49. package/dist/src/client/client.d.ts.map +0 -1
  50. package/dist/src/client/client.js.map +0 -1
  51. package/dist/src/client/engine.d.ts.map +0 -1
  52. package/dist/src/client/engine.js.map +0 -1
  53. package/dist/src/client/errors.d.ts.map +0 -1
  54. package/dist/src/client/errors.js.map +0 -1
  55. package/dist/src/client/http.d.ts.map +0 -1
  56. package/dist/src/client/http.js.map +0 -1
  57. package/dist/src/client/index.d.ts.map +0 -1
  58. package/dist/src/client/index.js.map +0 -1
  59. package/dist/src/client/query.d.ts.map +0 -1
  60. package/dist/src/client/query.js.map +0 -1
  61. package/dist/src/client/types.d.ts.map +0 -1
  62. package/dist/src/client/types.js.map +0 -1
  63. package/dist/src/index.d.ts.map +0 -1
  64. package/dist/src/index.js.map +0 -1
@@ -1,9 +1,11 @@
1
1
  // The request engine: turns logical (method, path, query) calls into HTTP
2
2
  // requests via a Transport, applies retry/backoff for transient statuses
3
3
  // (429, 503), and decodes responses.
4
- import { MAX_TIMEOUT_MS, nodeHttpTransport } from "./http.js";
4
+ import { TextDecoder } from "node:util";
5
+ import { MAX_TIMEOUT_MS, nodeHttpTransport, sizeLimitMessage, } from "./http.js";
5
6
  import { buildQueryString } from "./query.js";
6
- import { PegelApiError, PegelError, PegelNetworkError, PegelParseError, redactUrl } from "./errors.js";
7
+ import { PegelApiError, PegelError, PegelNetworkError, PegelParseError, PegelValidationError, credentialsIn, redactCredentials, redactUrl, } from "./errors.js";
8
+ import { assertValid, baseUrlProblem, headerValueProblem, knownKeysProblem } from "./validate.js";
7
9
  export const DEFAULT_BASE_URL = "https://www.pegelonline.wsv.de";
8
10
  const DEFAULT_USER_AGENT = "pegel-online-cli";
9
11
  const DEFAULT_MAX_RESPONSE_BYTES = 100 * 1024 * 1024;
@@ -20,10 +22,66 @@ function intOption(name, value, fallback, max) {
20
22
  if (value === undefined)
21
23
  return fallback;
22
24
  if (!Number.isSafeInteger(value) || value < 0 || value > max) {
23
- throw new PegelError(`Invalid option ${name}: expected an integer from 0 to ${max}, got ${String(value)}.`);
25
+ throw new PegelValidationError(`Invalid option ${name}: expected an integer from 0 to ${max}, got ${typeof value === "number" ? String(value) : typeof value}.`);
24
26
  }
25
27
  return value;
26
28
  }
29
+ /** Every EngineOptions key; any other is rejected (a JavaScript `timeout` for `timeoutMs`). */
30
+ const OPTION_NAMES = [
31
+ "baseUrl",
32
+ "transport",
33
+ "userAgent",
34
+ "headers",
35
+ "timeoutMs",
36
+ "maxRetries",
37
+ "retryDelayMs",
38
+ "maxRedirects",
39
+ "maxResponseBytes",
40
+ "sleep",
41
+ ];
42
+ /**
43
+ * Read a function option: `undefined` gives the default; anything else that is not a
44
+ * function throws. A string `transport` used to fail at the first request as a raw
45
+ * TypeError, and a bad `sleep` on the first retry.
46
+ */
47
+ function functionOption(name, value, fallback) {
48
+ if (value === undefined)
49
+ return fallback;
50
+ if (typeof value !== "function") {
51
+ throw new PegelValidationError(`Invalid option ${name}: expected a function, got ${typeof value}.`);
52
+ }
53
+ return value;
54
+ }
55
+ /**
56
+ * Read the `headers` option: a plain object of header values, each one an HTTP header
57
+ * can carry (headerValueProblem). An array or a non-string value used to reach Node as
58
+ * an opaque TypeError at request time.
59
+ */
60
+ function headersOption(value) {
61
+ if (value === undefined)
62
+ return {};
63
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
64
+ throw new PegelValidationError(`Invalid option headers: expected an object of header values, got ${Array.isArray(value) ? "an array" : typeof value}.`);
65
+ }
66
+ for (const [name, header] of Object.entries(value)) {
67
+ const reason = headerValueProblem(header);
68
+ if (reason !== undefined)
69
+ throw new PegelValidationError(`Invalid option headers: ${JSON.stringify(name)}: ${reason}`);
70
+ }
71
+ return { ...value };
72
+ }
73
+ /**
74
+ * Longest server text (in characters) kept for an error message: an error `detail`, a
75
+ * transport's error text or a redirect target. A longer one is cut and ends in "…", so a
76
+ * hostile or buggy body cannot flood stderr or a CI log with one huge line.
77
+ * `PegelApiError.body` keeps the full text.
78
+ */
79
+ const MAX_DETAIL_LENGTH = 500;
80
+ /** sanitizeServerText, then cut at MAX_DETAIL_LENGTH characters. */
81
+ function cleanDetail(text) {
82
+ const clean = sanitizeServerText(text);
83
+ return clean.length > MAX_DETAIL_LENGTH ? `${clean.slice(0, MAX_DETAIL_LENGTH)}…` : clean;
84
+ }
27
85
  /**
28
86
  * The redirect statuses the engine follows. 300 (a choice for the user), 304 (a
29
87
  * cache answer to a conditional request this client never sends) and 305/306
@@ -33,8 +91,10 @@ const FOLLOWED_REDIRECTS = new Set([301, 302, 303, 307, 308]);
33
91
  /**
34
92
  * Longest `Retry-After` the engine waits out before retrying a 429/503. When the
35
93
  * server asks for longer, the engine does not retry at all and surfaces the error at
36
- * once: retrying early would only land inside the window the server asked us to wait
37
- * out, and a hostile value must not stall the CLI.
94
+ * once, naming the requested wait (`PegelApiError.retryAfterMs`): retrying early would
95
+ * only land inside the window the server asked us to wait out, and a hostile value must
96
+ * not stall the CLI. A shorter `Retry-After` never makes a wait shorter than the normal
97
+ * backoff.
38
98
  */
39
99
  export const MAX_RETRY_AFTER_MS = 30_000;
40
100
  /** An IMF-fixdate (RFC 9110 §5.6.7), the one HTTP-date form senders must generate. */
@@ -60,6 +120,88 @@ export function parseRetryAfter(header, now = Date.now()) {
60
120
  const when = Date.parse(value);
61
121
  return Number.isNaN(when) ? undefined : Math.max(0, when - now);
62
122
  }
123
+ /** Why `value` is not a usable HttpResponse, or undefined when it is. */
124
+ function responseProblem(value) {
125
+ if (typeof value !== "object" || value === null)
126
+ return "not an object";
127
+ const r = value;
128
+ if (typeof r.status !== "number" || !Number.isInteger(r.status) || r.status < 100 || r.status > 599) {
129
+ return "status is not an HTTP status code";
130
+ }
131
+ if (typeof r.headers !== "object" || r.headers === null || Array.isArray(r.headers))
132
+ return "headers is not an object";
133
+ if (bodyBytes(r.body) === undefined)
134
+ return "body is not a Buffer, Uint8Array, other ArrayBuffer view or ArrayBuffer";
135
+ return undefined;
136
+ }
137
+ /**
138
+ * The response body as a Buffer (a view, no copy): a Buffer, any ArrayBuffer view (a
139
+ * Uint8Array from fetch, a DataView) or an ArrayBuffer/SharedArrayBuffer — checked by
140
+ * internal slot, not `instanceof`, so a value from another realm (a vm context, a Jest
141
+ * test) counts. Undefined for anything else. (`Uint8Array#toString` ignores an encoding
142
+ * argument and yields "123,34,…", which is how a fetch body used to fail to parse.)
143
+ */
144
+ function bodyBytes(value) {
145
+ if (Buffer.isBuffer(value))
146
+ return value;
147
+ if (ArrayBuffer.isView(value))
148
+ return Buffer.from(value.buffer, value.byteOffset, value.byteLength);
149
+ const tag = Object.prototype.toString.call(value);
150
+ if (tag === "[object ArrayBuffer]" || tag === "[object SharedArrayBuffer]")
151
+ return Buffer.from(value);
152
+ return undefined;
153
+ }
154
+ /**
155
+ * The response headers as a plain record with lower-case names. A transport built on
156
+ * `fetch` naturally returns its `Headers` object, which has no plain properties, and a
157
+ * custom one may write `Retry-After` or `Location` capitalised: the engine then saw no
158
+ * Retry-After (and retried at once) and no Location (and failed the redirect). Such an
159
+ * object (anything with `get` and `forEach`, a `Map` included) is copied; a plain record
160
+ * gets its names lower-cased.
161
+ */
162
+ function plainHeaders(headers) {
163
+ const h = headers;
164
+ if (typeof h.get === "function" && typeof h.forEach === "function") {
165
+ const record = {};
166
+ h.forEach.call(headers, (value, name) => {
167
+ // Headers#forEach gives (value, name), and so does Map#forEach.
168
+ record[String(name).toLowerCase()] = String(value);
169
+ });
170
+ return record;
171
+ }
172
+ const record = {};
173
+ for (const [name, value] of Object.entries(headers)) {
174
+ record[name.toLowerCase()] = value;
175
+ }
176
+ return record;
177
+ }
178
+ /** One header value as a string (the first of a repeated one), or undefined. */
179
+ function headerValue(value) {
180
+ return Array.isArray(value) ? value[0] : value;
181
+ }
182
+ /**
183
+ * Error codes of a connection that broke off mid-request: Node's (`socket hang up` is
184
+ * ECONNRESET) and undici's (`fetch failed` with cause UND_ERR_SOCKET, "other side closed").
185
+ */
186
+ const TRANSIENT_NETWORK_CODES = new Set(["ECONNRESET", "EPIPE", "ECONNABORTED", "UND_ERR_SOCKET"]);
187
+ /** True when `err` or an error in its `cause` chain has a transient connection code. */
188
+ function hasTransientCode(err, depth = 0) {
189
+ if (typeof err !== "object" || err === null || depth > 4)
190
+ return false;
191
+ const code = err.code;
192
+ if (typeof code === "string" && TRANSIENT_NETWORK_CODES.has(code))
193
+ return true;
194
+ return hasTransientCode(err.cause, depth + 1);
195
+ }
196
+ /**
197
+ * True for a PegelNetworkError caused by a reset or aborted connection, which the
198
+ * engine retries — whichever transport raised it (a Node error, fetch's TypeError with
199
+ * an undici cause). A refused connection, a DNS failure or a timeout is not transient
200
+ * in that sense and is not retried.
201
+ */
202
+ export function isTransientNetworkError(err) {
203
+ return err instanceof PegelNetworkError && hasTransientCode(err.cause);
204
+ }
63
205
  /**
64
206
  * Credential-bearing headers that must never be carried across an origin boundary
65
207
  * on a redirect. Stored lower-cased and compared case-insensitively so a header
@@ -78,10 +220,34 @@ const CREDENTIAL_HEADERS = new Set([
78
220
  * guarantee correct regardless of the casing the caller used.
79
221
  */
80
222
  function stripSensitiveHeaders(headers) {
223
+ let stripped = false;
81
224
  for (const key of Object.keys(headers)) {
82
- if (CREDENTIAL_HEADERS.has(key.toLowerCase()))
225
+ if (CREDENTIAL_HEADERS.has(key.toLowerCase())) {
83
226
  delete headers[key];
227
+ stripped = true;
228
+ }
229
+ }
230
+ return stripped;
231
+ }
232
+ /** True when `a` and `b` parse and share scheme, host and port; false otherwise. */
233
+ function sameOrigin(a, b) {
234
+ try {
235
+ return new URL(a).origin === new URL(b).origin;
236
+ }
237
+ catch {
238
+ return false;
239
+ }
240
+ }
241
+ /**
242
+ * Why a 401/403 after a redirect to another origin may not be the credentials' fault:
243
+ * the engine did not send them there. An http->https upgrade on the same host gets its
244
+ * own advice.
245
+ */
246
+ function credentialsDroppedHint(from, to) {
247
+ if (from.protocol === "http:" && to.protocol === "https:" && from.hostname === to.hostname) {
248
+ return "the server redirected http to https, so the credentials were not sent there; use an https base URL";
84
249
  }
250
+ return `the server redirected to another origin (${to.origin}), so the credentials were not sent there; use that origin as the base URL if it should get them`;
85
251
  }
86
252
  /**
87
253
  * Strip control characters (all C0/C1 controls except tab and newline, plus DEL)
@@ -104,36 +270,28 @@ function sanitizeServerText(text) {
104
270
  return out;
105
271
  }
106
272
  /**
107
- * Reject a base URL whose scheme is not http(s), or that has a query or fragment.
108
- * The default transport already gates the scheme per hop, but the engine is
109
- * exported as a library and may be handed a custom transport that does no such
110
- * check, so gate the configured base URL here too (a `file:`/`ftp:` base URL fails
111
- * fast with a typed error). Request paths are appended to the base URL as a string,
112
- * so a `?` or `#` in it would swallow every path: `http://h/?x=1` requests
113
- * `/?x=1/webservices/...` and `http://h/#f` requests `/`. Userinfo is allowed (Node
114
- * sends it as Basic auth) but redacted in every message.
273
+ * Check a base URL against the library's rules (baseUrlProblem: no whitespace or
274
+ * control characters, an absolute http(s) URL, no query or fragment) and return it
275
+ * without trailing slashes. Throws PegelValidationError `Invalid baseUrl: …`: a
276
+ * configuration mistake, not a PegelNetworkError. The RequestEngine constructor
277
+ * calls it on the raw value, so a custom transport never sees a bad base URL; the
278
+ * default transport still re-checks the scheme on every hop.
115
279
  */
116
- function assertHttpScheme(baseUrl) {
117
- let url;
118
- try {
119
- url = new URL(baseUrl);
120
- }
121
- catch {
122
- throw new PegelNetworkError(`Invalid base URL: ${redactUrl(baseUrl)}`);
123
- }
124
- if (url.protocol !== "http:" && url.protocol !== "https:") {
125
- throw new PegelNetworkError(`Unsupported protocol "${url.protocol}" in base URL: ${redactUrl(baseUrl)}`);
126
- }
127
- if (/[?#]/.test(baseUrl)) {
128
- throw new PegelNetworkError(`Base URL must not contain a query or fragment: ${redactUrl(baseUrl)}`);
129
- }
280
+ export function validateBaseUrl(raw) {
281
+ return assertValid("baseUrl", raw, baseUrlProblem).replace(/\/+$/, "");
130
282
  }
131
283
  const realSleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
132
284
  export class RequestEngine {
133
- baseUrl;
285
+ // Real private fields (not TypeScript's `private`): util.inspect, console.log and
286
+ // JSON.stringify of a client never show them, so a password in the base URL, or an
287
+ // Authorization header a caller added, can't be logged by accident. Messages show the
288
+ // base URL through redactUrl.
289
+ #baseUrl;
290
+ /** The base URL's userinfo, raw and percent-decoded, for scrubbing server and transport text. */
291
+ #credentials;
292
+ #extraHeaders;
134
293
  transport;
135
294
  userAgent;
136
- extraHeaders;
137
295
  timeoutMs;
138
296
  maxRetries;
139
297
  retryDelayMs;
@@ -141,32 +299,43 @@ export class RequestEngine {
141
299
  maxResponseBytes;
142
300
  sleep;
143
301
  constructor(options = {}) {
144
- this.baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
145
- assertHttpScheme(this.baseUrl);
146
- this.transport = options.transport ?? nodeHttpTransport;
147
- this.userAgent = options.userAgent ?? DEFAULT_USER_AGENT;
148
- // Reject what Node's header validation would throw a raw TypeError for (it
149
- // would surface as an "Unexpected error") with a typed error up front: control
150
- // characters (CR/LF in particular, which also closes header injection; tab is
151
- // allowed, as in HTTP) and characters above U+00FF.
152
- if (/[\x00-\x08\x0a-\x1f\x7f]/.test(this.userAgent)) {
153
- throw new PegelError("Invalid User-Agent: control characters are not allowed.");
154
- }
155
- if (/[^\x00-\xff]/.test(this.userAgent)) {
156
- throw new PegelError("Invalid User-Agent: characters outside Latin-1 (above U+00FF) are not allowed.");
157
- }
158
- this.extraHeaders = options.headers ?? {};
302
+ // A JavaScript caller may pass null for "no options"; treat it like undefined.
303
+ options = options ?? {};
304
+ // A typo (`timeout` for `timeoutMs`) used to be ignored and the default applied.
305
+ assertValid("options", options, knownKeysProblem(OPTION_NAMES));
306
+ // The raw value, before the slash strip: the engine glues it into every URL, so
307
+ // "https://h/ " must not lose its slash first and slip past the check.
308
+ this.#baseUrl = validateBaseUrl(options.baseUrl ?? DEFAULT_BASE_URL);
309
+ this.#credentials = credentialsIn(this.#baseUrl).flatMap((raw) => {
310
+ try {
311
+ return [raw, decodeURIComponent(raw)];
312
+ }
313
+ catch {
314
+ return [raw];
315
+ }
316
+ });
317
+ this.transport = functionOption("transport", options.transport, nodeHttpTransport);
318
+ // Only `undefined` selects the default. An explicit value must be one an HTTP
319
+ // header can carry (headerValueProblem): not blank, which would replace the
320
+ // default with an empty header, and no control character (CR/LF in particular,
321
+ // which also closes header injection; tab is allowed) or character above U+00FF,
322
+ // which Node would refuse late with a raw TypeError.
323
+ this.userAgent =
324
+ options.userAgent === undefined
325
+ ? DEFAULT_USER_AGENT
326
+ : assertValid("User-Agent", options.userAgent, headerValueProblem);
327
+ this.#extraHeaders = headersOption(options.headers);
159
328
  this.timeoutMs = intOption("timeoutMs", options.timeoutMs, 30_000, MAX_TIMEOUT_MS);
160
329
  this.maxRetries = intOption("maxRetries", options.maxRetries, 2, MAX_RETRIES);
161
330
  this.retryDelayMs = intOption("retryDelayMs", options.retryDelayMs, 200, MAX_RETRY_AFTER_MS);
162
331
  this.maxRedirects = intOption("maxRedirects", options.maxRedirects, 5, MAX_REDIRECTS);
163
332
  this.maxResponseBytes = intOption("maxResponseBytes", options.maxResponseBytes, DEFAULT_MAX_RESPONSE_BYTES, Number.MAX_SAFE_INTEGER);
164
- this.sleep = options.sleep ?? realSleep;
333
+ this.sleep = functionOption("sleep", options.sleep, realSleep);
165
334
  }
166
335
  /**
167
336
  * Build a fully-qualified URL from a path and optional query parameters.
168
337
  *
169
- * Throws a PegelError for a path with a "." or ".." segment. The resource methods
338
+ * Throws a PegelValidationError for a path with a "." or ".." segment. The resource methods
170
339
  * put ids into the path with `encodeURIComponent`, which leaves those two
171
340
  * unchanged, and URL parsing then resolves them: `currentMeasurement("BONN", "..")`
172
341
  * would request `/stations/currentmeasurement.json` (a station of that name).
@@ -177,58 +346,184 @@ export class RequestEngine {
177
346
  const normalizedPath = path.startsWith("/") ? path : `/${path}`;
178
347
  const dotSegment = normalizedPath.split("/").find((s) => s === "." || s === "..");
179
348
  if (dotSegment !== undefined) {
180
- throw new PegelError(`Invalid path segment "${dotSegment}" in ${normalizedPath}: "." and ".." cannot be used as an id.`);
349
+ throw new PegelValidationError(`Invalid path segment "${dotSegment}" in ${normalizedPath}: "." and ".." cannot be used as an id.`);
181
350
  }
182
351
  const qs = query ? buildQueryString(query) : "";
183
- return `${this.baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
352
+ return `${this.#baseUrl}${normalizedPath}${qs ? `?${qs}` : ""}`;
353
+ }
354
+ /**
355
+ * `text` without the base URL's credentials: server text (an error body that echoes
356
+ * the request URL) and transport text (fetch's "Failed to fetch <url>") can carry them.
357
+ */
358
+ scrub(text) {
359
+ return this.#credentials.length === 0 ? text : redactCredentials(text, this.#credentials);
360
+ }
361
+ /**
362
+ * A transport failure as the `cause` of the error the engine raises: the original
363
+ * when its text carries no credentials, otherwise a copy with them scrubbed (message,
364
+ * `code` and the cause chain kept), so logging the error with its causes can't reveal
365
+ * the base URL's password.
366
+ */
367
+ scrubCause(cause, depth = 0) {
368
+ if (this.#credentials.length === 0 || depth > 5)
369
+ return cause;
370
+ if (typeof cause === "string")
371
+ return this.scrub(cause);
372
+ if (!(cause instanceof Error))
373
+ return cause;
374
+ const inner = this.scrubCause(cause.cause, depth + 1);
375
+ const message = this.scrub(cause.message);
376
+ if (message === cause.message && inner === cause.cause && !this.scrub(cause.stack ?? "").includes("***@"))
377
+ return cause;
378
+ const copy = new Error(message, inner === undefined ? undefined : { cause: inner });
379
+ copy.name = cause.name;
380
+ const code = cause.code;
381
+ if (code !== undefined)
382
+ Object.assign(copy, { code });
383
+ return copy;
384
+ }
385
+ /**
386
+ * Call the transport under the overall deadline (`timeoutMs`): the request gets an
387
+ * AbortSignal that fires at the deadline, and the call rejects then whether the
388
+ * transport stops or not — a custom transport (fetch, a node:http wrapper) that
389
+ * ignores `timeoutMs` can't hang the caller. A synchronous throw becomes a rejection.
390
+ */
391
+ async callTransport(request) {
392
+ const call = (signal) => Promise.resolve().then(() => this.transport(signal === undefined ? request : { ...request, signal }));
393
+ if (this.timeoutMs === 0)
394
+ return call();
395
+ const controller = new AbortController();
396
+ let timer;
397
+ const deadline = new Promise((_, reject) => {
398
+ timer = setTimeout(() => {
399
+ const err = new PegelNetworkError(`Request timed out after ${this.timeoutMs}ms`);
400
+ controller.abort(err);
401
+ reject(err);
402
+ }, this.timeoutMs);
403
+ timer.unref?.();
404
+ });
405
+ try {
406
+ return await Promise.race([call(controller.signal), deadline]);
407
+ }
408
+ finally {
409
+ clearTimeout(timer);
410
+ }
184
411
  }
185
412
  /** Perform a request with Accept negotiation and transient-error retries. */
186
413
  async request(method, path, options = { accept: "application/json" }) {
187
414
  let url = this.buildUrl(path, options.query);
188
415
  const headers = {
189
- ...this.extraHeaders,
416
+ ...this.#extraHeaders,
190
417
  Accept: options.accept,
191
418
  "User-Agent": this.userAgent,
192
419
  };
420
+ // Only an idempotent request is sent again after a reset: request() is public, and
421
+ // a POST re-sent after a broken connection may be applied twice. The client itself
422
+ // sends GETs only.
423
+ const idempotent = /^(GET|HEAD)$/i.test(method);
193
424
  let attempt = 0;
194
425
  let redirects = 0;
426
+ /** Where a redirect to another origin dropped the credentials, for the 401/403 hint. */
427
+ let droppedCredentialsAt;
195
428
  // attempts = initial try + maxRetries (redirects are counted separately)
196
429
  for (;;) {
197
- const response = await this.transport({
198
- method,
199
- url,
200
- headers,
201
- timeoutMs: this.timeoutMs,
202
- ...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
203
- });
430
+ let response;
431
+ try {
432
+ response = await this.callTransport({
433
+ method,
434
+ url,
435
+ headers,
436
+ redirect: "manual",
437
+ timeoutMs: this.timeoutMs,
438
+ ...(this.maxResponseBytes > 0 ? { maxResponseBytes: this.maxResponseBytes } : {}),
439
+ });
440
+ }
441
+ catch (cause) {
442
+ // A connection the server (or a gateway) reset is the network-level twin of a
443
+ // 503: retry the GET, whichever transport reported it. Timeouts are not retried
444
+ // — a slow upstream should not be asked again at once.
445
+ if (idempotent && hasTransientCode(cause) && attempt < this.maxRetries) {
446
+ attempt += 1;
447
+ await this.sleep(this.retryDelayMs * attempt);
448
+ continue;
449
+ }
450
+ // The default transport rejects with PegelNetworkError only; an injected one may
451
+ // throw anything (a string, a TypeError, null). Keep the error contract for both:
452
+ // every failure is a PegelError. The message names the request — Node's text
453
+ // ("socket hang up") says nothing about which host or gauge failed — and the
454
+ // original is the `cause`. Any other PegelError passes through.
455
+ if (cause instanceof PegelError && !(cause instanceof PegelNetworkError))
456
+ throw cause;
457
+ const reason = cause instanceof Error ? cause.message : String(cause);
458
+ const retried = attempt > 0 ? ` (after ${attempt} ${attempt === 1 ? "retry" : "retries"})` : "";
459
+ throw new PegelNetworkError(`${method} ${redactUrl(url)} failed: ${cleanDetail(this.scrub(reason))}${retried}`, { cause: this.scrubCause(cause) });
460
+ }
461
+ // An injected transport may resolve with anything; a malformed HttpResponse would
462
+ // otherwise surface below as a raw TypeError, outside the PegelError contract.
463
+ const invalid = responseProblem(response);
464
+ if (invalid !== undefined) {
465
+ throw new PegelNetworkError(`${method} ${redactUrl(url)} failed: the transport returned an invalid response (${invalid}).`);
466
+ }
467
+ // Transports must not follow redirects (HttpRequest.redirect is "manual"); fetch does
468
+ // by default. One that reports a final URL on another origin has carried the
469
+ // request — and maybe a credential header fetch doesn't strip — somewhere the
470
+ // engine never vetted, so its answer is not trusted.
471
+ const finalUrl = response.url;
472
+ if (typeof finalUrl === "string" && finalUrl !== "" && !sameOrigin(finalUrl, url)) {
473
+ throw new PegelNetworkError(`${method} ${redactUrl(url)} failed: the transport followed a redirect to another origin ` +
474
+ `(${cleanDetail(redactUrl(finalUrl))}); a transport must not follow redirects (HttpRequest.redirect is "manual").`);
475
+ }
204
476
  const status = response.status;
477
+ const responseHeaders = plainHeaders(response.headers);
478
+ const body = bodyBytes(response.body);
479
+ // The size cap holds whatever the transport did: the default one aborts early, a
480
+ // custom one may have read everything.
481
+ if (this.maxResponseBytes > 0 && body.byteLength > this.maxResponseBytes) {
482
+ throw new PegelNetworkError(`${method} ${redactUrl(url)} failed: ${sizeLimitMessage(this.maxResponseBytes)}`);
483
+ }
205
484
  const retryable = status === 429 || status === 503;
485
+ // A Retry-After beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces
486
+ // at once and names the wait the server asked for.
487
+ let refusedWaitMs;
206
488
  if (retryable && attempt < this.maxRetries) {
207
- // Honour Retry-After; without a usable one, back off linearly. A Retry-After
208
- // beyond MAX_RETRY_AFTER_MS is not retried: the error below surfaces at once.
209
- const retryAfter = parseRetryAfter(response.headers["retry-after"]);
489
+ // Back off linearly (retryDelayMs × attempt). A Retry-After can make the wait
490
+ // longer, never shorter: `Retry-After: 0` or a date in the past used to turn the
491
+ // retries into a zero-delay burst against a server that had just asked for less.
492
+ const retryAfter = parseRetryAfter(responseHeaders["retry-after"]);
210
493
  if (retryAfter === undefined || retryAfter <= MAX_RETRY_AFTER_MS) {
211
494
  attempt += 1;
212
- await this.sleep(retryAfter ?? this.retryDelayMs * attempt);
495
+ const backoff = this.retryDelayMs * attempt;
496
+ await this.sleep(retryAfter === undefined ? backoff : Math.max(retryAfter, backoff));
213
497
  continue;
214
498
  }
499
+ refusedWaitMs = retryAfter;
215
500
  }
216
501
  // Follow redirects, resolving the Location relative to the current URL.
217
- const location = response.headers["location"];
502
+ const location = headerValue(responseHeaders["location"]);
218
503
  const next = FOLLOWED_REDIRECTS.has(status) ? resolveLocation(location, url) : undefined;
219
504
  if (next !== undefined && redirects >= this.maxRedirects) {
220
505
  // A loop (or a long chain): say how far it got rather than a bare 3xx.
221
506
  // (With maxRedirects 0 nothing was followed; the plain text says enough.)
222
- throw this.toApiError(method, url, status, response.body, location, redirects || undefined);
507
+ throw this.toApiError(method, url, status, body, location, redirects || undefined);
223
508
  }
224
509
  if (next !== undefined) {
225
- // Security: never carry credential-bearing headers across an origin
226
- // boundary. The CLI sends none today, but this guards a future
227
- // Authorization/Cookie/X-Api-Key header from leaking to an
228
- // attacker-controlled redirect target. Comparing full origin (scheme +
229
- // host + port) also strips on a same-host https->http downgrade.
230
- if (next.origin !== new URL(url).origin) {
231
- stripSensitiveHeaders(headers);
510
+ const current = new URL(url);
511
+ if (next.origin !== current.origin) {
512
+ // Security: never carry credentials across an origin boundary — neither
513
+ // credential-bearing headers (a caller's Authorization/Cookie/X-Api-Key) nor
514
+ // the base URL's userinfo, which an absolute Location to another host doesn't
515
+ // carry. Comparing full origin (scheme + host + port) also covers a same-host
516
+ // https->http downgrade and an http->https upgrade.
517
+ if (stripSensitiveHeaders(headers) || current.username !== "" || current.password !== "") {
518
+ droppedCredentialsAt = { from: current, to: next };
519
+ }
520
+ }
521
+ else if (next.username === "" && next.password === "") {
522
+ // Same origin: keep the base URL's userinfo. A relative Location inherits it
523
+ // when resolved; an absolute one (`Location: https://same-host/…`) used to
524
+ // drop it and turn a mirror login into a 401.
525
+ next.username = current.username;
526
+ next.password = current.password;
232
527
  }
233
528
  url = next.toString();
234
529
  redirects += 1;
@@ -236,17 +531,20 @@ export class RequestEngine {
236
531
  }
237
532
  // Any other 3xx — not a followed status, or no usable Location — falls
238
533
  // through and surfaces as a PegelApiError naming the target.
239
- const contentType = String(response.headers["content-type"] ?? "");
534
+ const contentType = String(headerValue(responseHeaders["content-type"]) ?? "");
240
535
  if (status < 200 || status >= 300) {
241
- throw this.toApiError(method, url, status, response.body, location);
536
+ const hint = (status === 401 || status === 403) && droppedCredentialsAt !== undefined
537
+ ? credentialsDroppedHint(droppedCredentialsAt.from, droppedCredentialsAt.to)
538
+ : undefined;
539
+ throw this.toApiError(method, url, status, body, location, undefined, refusedWaitMs, hint);
242
540
  }
243
- return { data: response.body, contentType, status };
541
+ return { data: body, contentType, status };
244
542
  }
245
543
  }
246
544
  /** Perform a GET expecting JSON and parse it into `T`. */
247
545
  async getJson(path, query) {
248
546
  const res = await this.request("GET", path, { query, accept: "application/json" });
249
- const text = res.data.toString("utf8");
547
+ const text = decodeBody(res.data, res.contentType, path);
250
548
  try {
251
549
  return JSON.parse(text);
252
550
  }
@@ -254,8 +552,8 @@ export class RequestEngine {
254
552
  throw new PegelParseError(`Failed to parse JSON response from ${path}`, { cause });
255
553
  }
256
554
  }
257
- toApiError(method, url, status, body, locationHeader, redirectsFollowed) {
258
- const text = body.toString("utf8");
555
+ toApiError(method, url, status, body, locationHeader, redirectsFollowed, retryAfterMs, hint) {
556
+ const text = this.scrub(body.toString("utf8"));
259
557
  let detail;
260
558
  try {
261
559
  const parsed = JSON.parse(text);
@@ -270,7 +568,7 @@ export class RequestEngine {
270
568
  // `detail` came from the response body; strip control characters so a hostile
271
569
  // endpoint cannot inject terminal escape sequences via the stderr error message.
272
570
  if (detail !== undefined)
273
- detail = sanitizeServerText(detail);
571
+ detail = cleanDetail(detail);
274
572
  // Name the target of a redirect that was not followed.
275
573
  const location = status >= 300 && status < 400 && locationHeader ? redirectTarget(url, locationHeader) : undefined;
276
574
  return new PegelApiError({
@@ -281,15 +579,41 @@ export class RequestEngine {
281
579
  detail,
282
580
  ...(location !== undefined ? { location } : {}),
283
581
  ...(redirectsFollowed !== undefined ? { redirectsFollowed } : {}),
582
+ ...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
583
+ ...(hint !== undefined ? { hint } : {}),
284
584
  });
285
585
  }
286
586
  }
287
- /** Resolve a Location header against the current URL; undefined if missing or malformed. */
587
+ /**
588
+ * Decode a response body by the charset its Content-Type names (UTF-8 when it names
589
+ * none). A proxy or mirror that answers in ISO-8859-1 used to come out as "K\uFFFDLN"
590
+ * with exit 0. TextDecoder also drops a leading byte order mark, which
591
+ * Buffer#toString keeps and JSON.parse then rejects. An unknown charset label is a
592
+ * PegelParseError.
593
+ */
594
+ function decodeBody(body, contentType, path) {
595
+ const charset = /;\s*charset\s*=\s*"?([^";\s]+)"?/i.exec(contentType)?.[1] ?? "utf-8";
596
+ let decoder;
597
+ try {
598
+ decoder = new TextDecoder(charset);
599
+ }
600
+ catch {
601
+ throw new PegelParseError(`Unsupported response charset "${sanitizeServerText(charset)}" from ${path}.`);
602
+ }
603
+ return decoder.decode(body);
604
+ }
605
+ /**
606
+ * Resolve a Location header against the current URL; undefined if missing, malformed
607
+ * or not http(s). A `file:`, `data:` or `javascript:` target is refused here, before
608
+ * any transport sees it (the default transport would refuse it too; a custom one may
609
+ * not), and surfaces as a PegelApiError naming the target.
610
+ */
288
611
  function resolveLocation(location, base) {
289
612
  if (location === undefined || location === "")
290
613
  return undefined;
291
614
  try {
292
- return new URL(location, base);
615
+ const next = new URL(location, base);
616
+ return next.protocol === "http:" || next.protocol === "https:" ? next : undefined;
293
617
  }
294
618
  catch {
295
619
  return undefined;
@@ -302,7 +626,6 @@ function resolveLocation(location, base) {
302
626
  */
303
627
  function redirectTarget(requestUrl, location) {
304
628
  const resolved = resolveLocation(location, requestUrl);
305
- const clean = sanitizeServerText(resolved ? redactUrl(resolved.href) : location).trim();
629
+ const clean = cleanDetail(resolved ? redactUrl(resolved.href) : location).trim();
306
630
  return clean === "" ? undefined : clean;
307
631
  }
308
- //# sourceMappingURL=engine.js.map