@chrischall/mcp-utils 2.15.0 → 3.0.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 (76) hide show
  1. package/README.md +493 -26
  2. package/dist/auth/index.d.ts +25 -1
  3. package/dist/auth/index.d.ts.map +1 -1
  4. package/dist/auth/index.js +105 -14
  5. package/dist/auth/index.js.map +1 -1
  6. package/dist/config/index.d.ts +13 -0
  7. package/dist/config/index.d.ts.map +1 -1
  8. package/dist/config/index.js +3 -2
  9. package/dist/config/index.js.map +1 -1
  10. package/dist/dates/index.d.ts +45 -5
  11. package/dist/dates/index.d.ts.map +1 -1
  12. package/dist/dates/index.js +64 -6
  13. package/dist/dates/index.js.map +1 -1
  14. package/dist/errors/index.d.ts +121 -7
  15. package/dist/errors/index.d.ts.map +1 -1
  16. package/dist/errors/index.js +154 -9
  17. package/dist/errors/index.js.map +1 -1
  18. package/dist/fs/output.d.ts +25 -8
  19. package/dist/fs/output.d.ts.map +1 -1
  20. package/dist/fs/output.js +27 -5
  21. package/dist/fs/output.js.map +1 -1
  22. package/dist/graphql/client.d.ts +8 -1
  23. package/dist/graphql/client.d.ts.map +1 -1
  24. package/dist/graphql/client.js +11 -3
  25. package/dist/graphql/client.js.map +1 -1
  26. package/dist/healthcheck/index.d.ts +11 -9
  27. package/dist/healthcheck/index.d.ts.map +1 -1
  28. package/dist/healthcheck/index.js +39 -23
  29. package/dist/healthcheck/index.js.map +1 -1
  30. package/dist/http/body-limit.d.ts +37 -0
  31. package/dist/http/body-limit.d.ts.map +1 -0
  32. package/dist/http/body-limit.js +86 -0
  33. package/dist/http/body-limit.js.map +1 -0
  34. package/dist/http/fetch-bounded.d.ts +91 -0
  35. package/dist/http/fetch-bounded.d.ts.map +1 -0
  36. package/dist/http/fetch-bounded.js +138 -0
  37. package/dist/http/fetch-bounded.js.map +1 -0
  38. package/dist/http/index.d.ts +231 -6
  39. package/dist/http/index.d.ts.map +1 -1
  40. package/dist/http/index.js +432 -56
  41. package/dist/http/index.js.map +1 -1
  42. package/dist/http/response-cache.d.ts +36 -3
  43. package/dist/http/response-cache.d.ts.map +1 -1
  44. package/dist/http/response-cache.js +84 -8
  45. package/dist/http/response-cache.js.map +1 -1
  46. package/dist/http/throttle.d.ts +37 -4
  47. package/dist/http/throttle.d.ts.map +1 -1
  48. package/dist/http/throttle.js +65 -6
  49. package/dist/http/throttle.js.map +1 -1
  50. package/dist/http/url-safety.d.ts +142 -0
  51. package/dist/http/url-safety.d.ts.map +1 -0
  52. package/dist/http/url-safety.js +211 -0
  53. package/dist/http/url-safety.js.map +1 -0
  54. package/dist/netguard/index.d.ts +196 -0
  55. package/dist/netguard/index.d.ts.map +1 -0
  56. package/dist/netguard/index.js +365 -0
  57. package/dist/netguard/index.js.map +1 -0
  58. package/dist/server/confirm-env.d.ts +17 -8
  59. package/dist/server/confirm-env.d.ts.map +1 -1
  60. package/dist/server/confirm-env.js +22 -13
  61. package/dist/server/confirm-env.js.map +1 -1
  62. package/dist/server/confirm-write.js +1 -1
  63. package/dist/server/confirm-write.js.map +1 -1
  64. package/dist/server/index.d.ts +6 -2
  65. package/dist/server/index.d.ts.map +1 -1
  66. package/dist/server/index.js +24 -9
  67. package/dist/server/index.js.map +1 -1
  68. package/dist/session/index.d.ts +6 -0
  69. package/dist/session/index.d.ts.map +1 -1
  70. package/dist/session/index.js +12 -1
  71. package/dist/session/index.js.map +1 -1
  72. package/dist/zod/index.d.ts +6 -0
  73. package/dist/zod/index.d.ts.map +1 -1
  74. package/dist/zod/index.js +8 -1
  75. package/dist/zod/index.js.map +1 -1
  76. package/package.json +11 -2
@@ -18,18 +18,49 @@
18
18
  */
19
19
  import { responseHeader } from '../internal/headers.js';
20
20
  import { currentCallSignal, withAmbientCancellation } from '../cancel/index.js';
21
- import { truncateErrorMessage } from '../errors/index.js';
21
+ import { McpToolError, UpstreamFormatError, messageOf, truncateErrorMessage } from '../errors/index.js';
22
22
  import { isCloudflareChallenge } from '../scrape/index.js';
23
23
  export * from './throttle.js';
24
24
  export * from './response-cache.js';
25
25
  export * from './net-atoms.js';
26
+ export * from './fetch-bounded.js';
27
+ export * from './url-safety.js';
28
+ import { readBytesCapped, ResponseTooLargeError } from './body-limit.js';
26
29
  import { parseRetryAfterMs } from './net-atoms.js';
30
+ import { RedirectRefusedError, findPathHazard } from './url-safety.js';
27
31
  import { retryAfterToMs } from '../internal/retry-after.js';
28
32
  const DEFAULT_RETRY = { count: 1, delayMs: 2000 };
29
33
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
30
- /** Thrown for an upstream 401. Carries the status so callers can trigger a re-auth. */
34
+ /**
35
+ * The per-attempt timeout {@link createApiClient} (and {@link fetchBounded})
36
+ * apply when the caller names none: 30 s, the same budget
37
+ * `createGraphqlClient` has always used, so every fleet transport bounds a
38
+ * hung upstream the same way.
39
+ */
40
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 30_000;
41
+ /** Default hop limit for `createApiClient({ redirect: 'same-origin' })`. */
42
+ export const DEFAULT_MAX_REDIRECTS = 5;
43
+ /** The statuses `redirect: 'same-origin'` follows (when they carry a `Location`). */
44
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
45
+ /** Cancel a response body we will not read. Never throws (a custom fetchImpl's body may be odd). */
46
+ function cancelBody(res) {
47
+ try {
48
+ const body = res.body;
49
+ body?.cancel?.()?.catch(() => { });
50
+ }
51
+ catch {
52
+ // nothing left to release
53
+ }
54
+ }
55
+ /**
56
+ * Thrown for an upstream 401. Carries the status so callers can trigger a
57
+ * re-auth, and `kind: 'credential_rejected'` (an `McpToolErrorKind`) so a
58
+ * classifier reads it without matching the message.
59
+ */
31
60
  export class UnauthorizedError extends Error {
32
61
  status = 401;
62
+ /** Always `'credential_rejected'` unless a subclass says otherwise. */
63
+ kind = 'credential_rejected';
33
64
  constructor(service) {
34
65
  super(`Unauthorized (401) from ${service} — the token is missing, invalid, or expired.`);
35
66
  this.name = 'UnauthorizedError';
@@ -43,6 +74,8 @@ export class UnauthorizedError extends Error {
43
74
  */
44
75
  export class RateLimitedError extends Error {
45
76
  status = 429;
77
+ /** `'http'`: the far side answered, with a 429 (see `status`). */
78
+ kind = 'http';
46
79
  retryAfterMs;
47
80
  constructor(service, retryAfterMs) {
48
81
  super(`Rate limited (429) by ${service} after retries.`);
@@ -51,9 +84,11 @@ export class RateLimitedError extends Error {
51
84
  Object.setPrototypeOf(this, new.target.prototype);
52
85
  }
53
86
  }
54
- /** Thrown when a request exceeds {@link ApiClientOptions.timeout}. */
87
+ /** Thrown when a request exceeds {@link ApiClientOptions.timeout}. Its `kind` is `'timeout'`. */
55
88
  export class RequestTimeoutError extends Error {
56
89
  timeoutMs;
90
+ /** Always `'timeout'` unless a subclass says otherwise. */
91
+ kind = 'timeout';
57
92
  constructor(service, timeoutMs) {
58
93
  super(`Request to ${service} timed out after ${timeoutMs}ms.`);
59
94
  this.name = 'RequestTimeoutError';
@@ -61,6 +96,56 @@ export class RequestTimeoutError extends Error {
61
96
  Object.setPrototypeOf(this, new.target.prototype);
62
97
  }
63
98
  }
99
+ /** The default `hint` on a {@link WriteOutcomeUnknownError}. */
100
+ export const DEFAULT_WRITE_OUTCOME_HINT = 'The write may have happened — check before retrying; do not resend blindly.';
101
+ /**
102
+ * A write (a request whose method is not safe) was SENT, but no usable
103
+ * response came back: it timed out, the connection dropped, or the response
104
+ * body broke off. The server may already have committed it, so the outcome is
105
+ * UNKNOWN, and re-sending it blindly can duplicate an email, a booking or a
106
+ * payment (fleet audit 2026-09, cluster 7: opentable #632, honeybook #1023,
107
+ * office-outlook #1073, vibo #1135, untappd #1132).
108
+ *
109
+ * An {@link McpToolError}, so its {@link McpToolError.hint} ("check before
110
+ * retrying") reaches the model through `wrapToolError` / `errorResult`. It
111
+ * still DECLARES a timeout when one caused it (`timedOut: true`, the marker
112
+ * `isTimeoutError` reads), but carries `retrySafe: false`, so
113
+ * `retryOnceOnTimeout` never replays it. Mirrors `GraphqlTransportError`'s
114
+ * `outcomeUnknown` and fetchproxy's `FetchproxyTimeoutError.retrySafe`.
115
+ *
116
+ * Thrown by {@link createApiClient} (see
117
+ * {@link ApiClientOptions.writeOutcomeUnknown}); exported so a hand-rolled
118
+ * client can throw the same thing.
119
+ */
120
+ export class WriteOutcomeUnknownError extends McpToolError {
121
+ /** Always `true`: whether the write happened is not known. */
122
+ outcomeUnknown = true;
123
+ /** Always `false`: never replay this request without checking first. */
124
+ retrySafe = false;
125
+ /** `true` when the request's timeout expired; `false` for a dropped connection or broken body. */
126
+ timedOut;
127
+ /** The timeout that expired, when {@link timedOut}. */
128
+ timeoutMs;
129
+ /** The request method, upper-cased. */
130
+ method;
131
+ constructor(service, method, opts = {}) {
132
+ const verb = method.toUpperCase();
133
+ const what = opts.timeoutMs !== undefined
134
+ ? `${verb} to ${service} timed out after ${opts.timeoutMs}ms`
135
+ : `${verb} to ${service} failed: ${truncateErrorMessage(messageOf(opts.cause), 200)}`;
136
+ super(`${what} — the write may already have been applied (outcome is unknown).`, {
137
+ hint: opts.hint ?? DEFAULT_WRITE_OUTCOME_HINT,
138
+ cause: opts.cause,
139
+ kind: opts.timeoutMs !== undefined ? 'timeout' : 'transport',
140
+ });
141
+ this.name = 'WriteOutcomeUnknownError';
142
+ this.timedOut = opts.timeoutMs !== undefined;
143
+ this.timeoutMs = opts.timeoutMs;
144
+ this.method = verb;
145
+ }
146
+ }
147
+ /** RFC 9110 safe methods: a request with one of these never writes. */
148
+ const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
64
149
  /**
65
150
  * Thrown by {@link ApiClient.fetchJson} / {@link ApiClient.fetchHtml} for a
66
151
  * non-2xx response that isn't a 401/429 (those map to
@@ -114,6 +199,8 @@ export class UpstreamHttpError extends ApiError {
114
199
  export class EdgeBlockedError extends ApiError {
115
200
  /** Which edge refused the request, e.g. `'CloudFront'` or `'Cloudflare'`. */
116
201
  vendor;
202
+ /** Always `'edge_blocked'`: the credential was never judged. */
203
+ kind = 'edge_blocked';
117
204
  /**
118
205
  * @param where The service the request was for, and — when the thrower knows
119
206
  * it — the method and path, which the message names.
@@ -218,6 +305,67 @@ export function detectEdgeBlock(input) {
218
305
  }
219
306
  return null;
220
307
  }
308
+ function jsonKind(value) {
309
+ if (value === null)
310
+ return 'null';
311
+ if (Array.isArray(value))
312
+ return 'array';
313
+ return typeof value;
314
+ }
315
+ /**
316
+ * Parse an upstream response body as JSON, turning every way it can fail into
317
+ * a typed, message-safe error instead of a raw `SyntaxError` (whose message
318
+ * quotes the body) or a later `TypeError` on `null`:
319
+ *
320
+ * - a body that is not JSON is first checked with {@link detectEdgeBlock}
321
+ * (a Cloudflare challenge served with a 200 becomes {@link EdgeBlockedError});
322
+ * otherwise it throws {@link UpstreamFormatError} with `received: 'non-json'`,
323
+ * naming the service, request, status and content type but never the body;
324
+ * - with `expect`, an empty body, `null`, a scalar or the wrong container
325
+ * throws {@link UpstreamFormatError} too.
326
+ *
327
+ * An empty (or whitespace-only) body without `expect` returns `undefined`, the
328
+ * same contract `fetchJson` has for a 204. `fetchJson` runs every 2xx body
329
+ * through this; export it for hand-rolled clients and OAuth token exchanges
330
+ * that read the body themselves (skylight, ofw).
331
+ */
332
+ export function parseJsonBody(text, opts = {}) {
333
+ const contentType = opts.contentType ?? headerOf(opts.headers, 'content-type');
334
+ const details = {
335
+ service: opts.service,
336
+ method: opts.method,
337
+ path: opts.path,
338
+ status: opts.status,
339
+ contentType,
340
+ expected: opts.expect,
341
+ };
342
+ if (text.trim().length === 0) {
343
+ if (opts.expect === undefined)
344
+ return undefined;
345
+ throw new UpstreamFormatError({ ...details, received: 'empty' });
346
+ }
347
+ let value;
348
+ try {
349
+ value = JSON.parse(text);
350
+ }
351
+ catch (cause) {
352
+ const edge = detectEdgeBlock({ body: text, headers: opts.headers, status: opts.status });
353
+ if (edge) {
354
+ throw new EdgeBlockedError(opts.status ?? 200, edge.vendor, {
355
+ service: opts.service ?? 'the upstream service',
356
+ method: opts.method,
357
+ path: opts.path,
358
+ });
359
+ }
360
+ throw new UpstreamFormatError({ ...details, expected: undefined, received: 'non-json', cause });
361
+ }
362
+ if (opts.expect !== undefined) {
363
+ const kind = jsonKind(value);
364
+ if (kind !== opts.expect)
365
+ throw new UpstreamFormatError({ ...details, received: kind });
366
+ }
367
+ return value;
368
+ }
221
369
  /**
222
370
  * The error a non-2xx response becomes: {@link EdgeBlockedError} when an edge
223
371
  * refused it, otherwise the {@link ApiError} the client has always thrown.
@@ -293,10 +441,32 @@ export function createApiClient(opts) {
293
441
  const sleep = opts.sleep ?? defaultSleep;
294
442
  const unauthorized = () => (opts.onUnauthorized ? opts.onUnauthorized() : new UnauthorizedError(service));
295
443
  const retryStatuses = retry.statuses ?? [429];
296
- const timeoutMs = opts.timeout;
444
+ const timeoutMs = opts.timeout === false ? 0 : (opts.timeout ?? DEFAULT_REQUEST_TIMEOUT_MS);
445
+ const redirectMode = opts.redirect;
446
+ const maxRedirects = opts.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
447
+ const reportWriteOutcome = opts.writeOutcomeUnknown !== false;
448
+ const defaultMaxBytes = checkMaxBytes('maxResponseBytes', opts.maxResponseBytes);
449
+ // NaN would make `hops >= maxRedirects` never true: an endless redirect loop.
450
+ if (!Number.isSafeInteger(maxRedirects) || maxRedirects < 0) {
451
+ throw new TypeError(`createApiClient: maxRedirects must be a non-negative integer, got ${String(maxRedirects)}.`);
452
+ }
297
453
  // Default: `Authorization: Bearer <token>`. With `tokenHeader`, the raw
298
454
  // token goes in that named header instead (no `Bearer ` prefix).
299
455
  const authHeader = (token) => !token ? {} : opts.tokenHeader ? { [opts.tokenHeader]: token } : { Authorization: `Bearer ${token}` };
456
+ /**
457
+ * Errors that came out of the TRANSPORT — `fetch` itself, or reading a
458
+ * response body — as opposed to whatever else runs in between (a
459
+ * `tokenManager` refresh, a redirect refusal, JSON parsing). Only these,
460
+ * and this client's own {@link RequestTimeoutError}, make a write's
461
+ * outcome unknown.
462
+ */
463
+ const transportFailures = new WeakSet();
464
+ const markTransportFailure = (err) => {
465
+ // A cancellation is not a failure: the caller's abort is rethrown untouched.
466
+ if (typeof err === 'object' && err !== null && err.name !== 'AbortError') {
467
+ transportFailures.add(err);
468
+ }
469
+ };
300
470
  // Bound `run` with an AbortController when a timeout is configured, mapping the
301
471
  // abort to a RequestTimeoutError. No timeout → no timer (the caller's
302
472
  // cancellation still applies).
@@ -311,9 +481,9 @@ export function createApiClient(opts) {
311
481
  // NO TIMEOUT is no longer "no signal": a service configured without one
312
482
  // still honours the caller, which is the case where it matters most,
313
483
  // since nothing else was ever going to stop that request.
314
- if (timeoutMs == null || timeoutMs <= 0) {
484
+ if (timeoutMs <= 0) {
315
485
  const res = await run(withAmbientCancellation(undefined));
316
- return { res, done: () => { }, expired: undefined };
486
+ return { res, done: () => { }, expired: undefined, timedOut: () => false };
317
487
  }
318
488
  const controller = new AbortController();
319
489
  const timer = setTimeout(() => controller.abort(), timeoutMs);
@@ -340,7 +510,7 @@ export function createApiClient(opts) {
340
510
  });
341
511
  // Only observed if a body read is racing it; never an unhandled rejection.
342
512
  expired.catch(() => { });
343
- return { res, done: () => clearTimeout(timer), expired };
513
+ return { res, done: () => clearTimeout(timer), expired, timedOut: () => controller.signal.aborted };
344
514
  }
345
515
  /**
346
516
  * Abandon `attempt` without reading its body: disarm the timer AND cancel
@@ -353,13 +523,7 @@ export function createApiClient(opts) {
353
523
  */
354
524
  function discard(attempt) {
355
525
  attempt.done();
356
- try {
357
- const body = attempt.res.body;
358
- body?.cancel?.()?.catch(() => { });
359
- }
360
- catch {
361
- // A body that refuses cancellation has nothing left to release.
362
- }
526
+ cancelBody(attempt.res);
363
527
  }
364
528
  /**
365
529
  * Read the body of `attempt` under its timeout, then disarm the timer.
@@ -374,15 +538,70 @@ export function createApiClient(opts) {
374
538
  return await Promise.race([read(attempt.res), attempt.expired]);
375
539
  }
376
540
  catch (err) {
377
- if (err instanceof RequestTimeoutError) {
378
- attempt.res.body?.cancel().catch(() => { });
541
+ // The body's own abort listener was registered (at fetch time) before
542
+ // the timer's, so when OUR timer fires its `AbortError` can win the
543
+ // race: that is still this timeout, not a caller cancellation.
544
+ const timeout = err instanceof RequestTimeoutError ||
545
+ (err instanceof Error && err.name === 'AbortError' && attempt.timedOut());
546
+ if (timeout) {
547
+ cancelBody(attempt.res);
548
+ throw err instanceof RequestTimeoutError ? err : new RequestTimeoutError(service, timeoutMs);
379
549
  }
550
+ // The response arrived; only its size was refused. Not a transport
551
+ // failure, so a write that hit the cap is not outcome-unknown.
552
+ if (!(err instanceof ResponseTooLargeError))
553
+ markTransportFailure(err);
380
554
  throw err;
381
555
  }
382
556
  finally {
383
557
  attempt.done();
384
558
  }
385
559
  }
560
+ /**
561
+ * The body of `attempt` as bytes under `maxBytes` (`undefined` = no cap),
562
+ * through {@link readBody} so the timer, the timeout mapping and the
563
+ * transport-failure bookkeeping are the same as an uncapped read. Uncapped,
564
+ * it is exactly the historical `arrayBuffer()` read.
565
+ */
566
+ function readBytes(attempt, maxBytes) {
567
+ if (maxBytes === undefined)
568
+ return readBody(attempt, async (r) => new Uint8Array(await r.arrayBuffer()));
569
+ // Not redundant with readBody's own race: that one rejects the CALL on
570
+ // timeout, but the reader holds the stream's lock, so readBody's
571
+ // `cancelBody` cannot cancel it. Racing each `read()` makes the pending
572
+ // read reject too, and readBytesCapped then cancels the reader itself, so a
573
+ // stalled body is released instead of held until GC.
574
+ const race = (p) => (attempt.expired ? Promise.race([p, attempt.expired]) : p);
575
+ return readBody(attempt, (r) => readBytesCapped(r, maxBytes, service, race));
576
+ }
577
+ /** The body as text under `maxBytes`; uncapped, exactly `res.text()`. */
578
+ async function readText(attempt, maxBytes) {
579
+ if (maxBytes === undefined)
580
+ return readBody(attempt, (r) => r.text());
581
+ return new TextDecoder().decode(await readBytes(attempt, maxBytes));
582
+ }
583
+ /**
584
+ * A non-2xx's error body under the cap. Over it, the body is dropped ('')
585
+ * so the caller still throws the status-carrying {@link ApiError} rather
586
+ * than a size error that hides the status.
587
+ */
588
+ function readErrorText(attempt, maxBytes) {
589
+ return readText(attempt, maxBytes).catch((err) => {
590
+ if (err instanceof ResponseTooLargeError)
591
+ return '';
592
+ throw err;
593
+ });
594
+ }
595
+ /**
596
+ * A 401 / 429 body read only to scan it for an edge refusal page, under the
597
+ * call's cap. Any failure — over the cap, the timer, a broken stream — is
598
+ * no evidence of a block (''), so the status-specific error still throws.
599
+ */
600
+ function scanText(attempt, maxBytes) {
601
+ return readText(attempt, maxBytes).catch(() => '');
602
+ }
603
+ /** This call's cap: its own `maxBytes`, else the client default. */
604
+ const capOf = (opt) => opt.maxBytes !== undefined ? checkMaxBytes('maxBytes', opt.maxBytes) : defaultMaxBytes;
386
605
  // The base's own origin and userinfo. A baseUrl may legitimately carry
387
606
  // userinfo (`https://u:pw@host`); a resolved URL must then carry exactly
388
607
  // that userinfo, never a different or newly-introduced one.
@@ -405,6 +624,19 @@ export function createApiClient(opts) {
405
624
  * can neither introduce nor change it. Checked before any token is minted.
406
625
  */
407
626
  function resolveUrl(path, query) {
627
+ // Dot segments and backslashes first: they stay on the base ORIGIN, so the
628
+ // origin check below never saw them, but normalisation rewrites them —
629
+ // `/trails/../admin` is sent as `/admin` with the credential attached
630
+ // (fleet audit 2026-09, cluster 5). `encodeURIComponent('..')` is `..`,
631
+ // so encoding the value was never enough; see `apiPath`.
632
+ const hazard = findPathHazard(path);
633
+ if (hazard !== undefined) {
634
+ throw new Error(
635
+ // Quote only the path part: this fires on hostile input, and the
636
+ // query may carry values that do not belong in a log line.
637
+ `Refusing request to ${service}: path ${JSON.stringify(path.split(/[?#]/, 1)[0])} contains a ${hazard}, which URL ` +
638
+ 'normalisation would rewrite. Build paths with apiPath`...` so each value is one encoded segment.');
639
+ }
408
640
  const url = `${base}${path}${query}`;
409
641
  if (baseOrigin === undefined)
410
642
  return url; // unparseable base: nothing to compare against
@@ -423,25 +655,80 @@ export function createApiClient(opts) {
423
655
  }
424
656
  return url;
425
657
  }
426
- async function send(method, path, opt) {
658
+ /**
659
+ * Resolve a redirect's `Location` against the URL that returned it and
660
+ * refuse it unless it stays on the base origin with the base's own
661
+ * userinfo — the same rule {@link resolveUrl} applies to a path. The message
662
+ * names only the target origin: the rest may carry a token.
663
+ */
664
+ function sameOriginHop(location, from) {
665
+ let next;
666
+ try {
667
+ next = new URL(location, from);
668
+ }
669
+ catch {
670
+ throw new RedirectRefusedError(`Refusing redirect from ${service}: the Location header is not a valid URL.`);
671
+ }
672
+ if (baseOrigin === undefined ||
673
+ next.origin !== baseOrigin ||
674
+ next.username !== baseParts?.username ||
675
+ next.password !== baseParts?.password) {
676
+ throw new RedirectRefusedError(`Refusing redirect from ${service} to ${next.origin}: it leaves the base origin ${baseOrigin ?? '(unparseable)'}, ` +
677
+ 'and following it would re-send the credential there.');
678
+ }
679
+ return next.href;
680
+ }
681
+ async function send(method, path, opt, dispatch) {
427
682
  const { body: reqBody, contentType } = encodeBody(opt);
428
683
  const query = opt.query ? buildQueryString(opt.query) : '';
429
684
  const url = resolveUrl(path, query);
430
685
  const bodyInit = reqBody !== undefined ? { body: reqBody } : {};
431
686
  // One fetch with the given token; Authorization comes last from the auth
432
687
  // mechanism, then per-request headers can still override it if needed.
433
- const fetchWith = (token, signal) => doFetch(url, {
434
- method,
435
- headers: {
436
- Accept: 'application/json',
437
- ...(contentType !== undefined ? { 'Content-Type': contentType } : {}),
438
- ...opts.baseHeaders,
439
- ...authHeader(token || undefined),
440
- ...opt.headers,
441
- },
442
- ...(signal ? { signal } : {}),
443
- ...bodyInit,
444
- });
688
+ const fetchHop = (hopUrl, hopMethod, withBody, token, signal) => {
689
+ // A fetch handed an already-aborted signal rejects without sending.
690
+ if (!signal?.aborted)
691
+ dispatch.sent = true;
692
+ return doFetch(hopUrl, {
693
+ method: hopMethod,
694
+ headers: {
695
+ Accept: 'application/json',
696
+ ...(withBody && contentType !== undefined ? { 'Content-Type': contentType } : {}),
697
+ ...opts.baseHeaders,
698
+ ...authHeader(token || undefined),
699
+ ...opt.headers,
700
+ },
701
+ ...(signal ? { signal } : {}),
702
+ ...(redirectMode !== undefined ? { redirect: redirectMode === 'same-origin' ? 'manual' : redirectMode } : {}),
703
+ ...(withBody ? bodyInit : {}),
704
+ }).catch((err) => {
705
+ markTransportFailure(err);
706
+ throw err;
707
+ });
708
+ };
709
+ const fetchWith = async (token, signal) => {
710
+ if (redirectMode !== 'same-origin')
711
+ return fetchHop(url, method, true, token, signal);
712
+ let hopUrl = url;
713
+ let hopMethod = method;
714
+ let withBody = true;
715
+ for (let hops = 0;; hops += 1) {
716
+ const res = await fetchHop(hopUrl, hopMethod, withBody, token, signal);
717
+ const location = REDIRECT_STATUSES.has(res.status) ? responseHeader(res, 'location') : undefined;
718
+ if (location === undefined || location === null)
719
+ return res;
720
+ cancelBody(res);
721
+ if (hops >= maxRedirects) {
722
+ throw new RedirectRefusedError(`Refusing request to ${service}: too many redirects (more than ${maxRedirects}).`);
723
+ }
724
+ hopUrl = sameOriginHop(location, hopUrl);
725
+ if (res.status === 303 || ((res.status === 301 || res.status === 302) && hopMethod === 'POST')) {
726
+ if (hopMethod !== 'HEAD')
727
+ hopMethod = 'GET';
728
+ withBody = false;
729
+ }
730
+ }
731
+ };
445
732
  // tokenManager (reactive refresh + 401-replay) takes precedence over getToken.
446
733
  // Each attempt is wrapped by withTimeout, so the abort signal reaches fetch
447
734
  // through whichever auth path is in play.
@@ -482,14 +769,14 @@ export function createApiClient(opts) {
482
769
  * be read is treated as no evidence of a block. The body is only scanned,
483
770
  * never echoed, so the token cannot leak through it.
484
771
  */
485
- async function unauthorizedOrEdge(attempt, method, path) {
772
+ async function unauthorizedOrEdge(attempt, method, path, maxBytes) {
486
773
  const res = attempt.res;
487
774
  let text = '';
488
775
  if (/json/i.test(responseHeader(res, 'content-type') ?? '')) {
489
776
  discard(attempt);
490
777
  }
491
778
  else {
492
- text = await readBody(attempt, (r) => r.text()).catch(() => '');
779
+ text = await scanText(attempt, maxBytes);
493
780
  }
494
781
  const edge = detectEdgeBlock({ body: text, headers: res.headers, status: res.status });
495
782
  if (edge)
@@ -503,7 +790,7 @@ export function createApiClient(opts) {
503
790
  * for an edge refusal page (same rule and JSON short-cut as
504
791
  * {@link unauthorizedOrEdge}), then the hook gets the whole context.
505
792
  */
506
- async function rateLimitedError(attempt, method, path) {
793
+ async function rateLimitedError(attempt, method, path, maxBytes) {
507
794
  const res = attempt.res;
508
795
  const retryAfter = responseHeader(res, 'retry-after');
509
796
  const retryAfterMs = retryAfterToMs(retryAfter);
@@ -516,61 +803,99 @@ export function createApiClient(opts) {
516
803
  discard(attempt);
517
804
  }
518
805
  else {
519
- text = await readBody(attempt, (r) => r.text()).catch(() => '');
806
+ text = await scanText(attempt, maxBytes);
520
807
  }
521
808
  const edgeBlock = detectEdgeBlock({ body: text, headers: res.headers, status: res.status });
522
809
  return opts.onRateLimited({ status: res.status, retryAfter, retryAfterMs, edgeBlock, method, path });
523
810
  }
524
- async function fetchJson(method, path, opt = {}) {
525
- const attempt = await send(method, path, opt);
811
+ /**
812
+ * Run one client call, turning a SENT write's transport failure into
813
+ * {@link WriteOutcomeUnknownError}. Everything else — a read, an
814
+ * `idempotent` request, a failure before anything was sent, any HTTP
815
+ * response, a caller's cancellation — propagates exactly as before.
816
+ */
817
+ async function guardWrite(method, opt, call) {
818
+ const dispatch = { sent: false };
819
+ try {
820
+ return await call(dispatch);
821
+ }
822
+ catch (err) {
823
+ const write = reportWriteOutcome && opt.idempotent !== true && !SAFE_METHODS.has(method.toUpperCase());
824
+ if (!write || !dispatch.sent)
825
+ throw err;
826
+ if (err instanceof RequestTimeoutError) {
827
+ throw new WriteOutcomeUnknownError(service, method, {
828
+ timeoutMs: err.timeoutMs,
829
+ cause: err,
830
+ hint: opts.writeOutcomeHint,
831
+ });
832
+ }
833
+ if (typeof err === 'object' && err !== null && transportFailures.has(err)) {
834
+ throw new WriteOutcomeUnknownError(service, method, { cause: err, hint: opts.writeOutcomeHint });
835
+ }
836
+ throw err;
837
+ }
838
+ }
839
+ const fetchJson = (method, path, opt = {}) => guardWrite(method, opt, (dispatch) => fetchJsonOnce(method, path, opt, dispatch));
840
+ const fetchHtml = (method, path, opt = {}) => guardWrite(method, opt, (dispatch) => fetchHtmlOnce(method, path, opt, dispatch));
841
+ const fetchRaw = (method, path, opt = {}) => guardWrite(method, opt, (dispatch) => fetchRawOnce(method, path, opt, dispatch));
842
+ async function fetchJsonOnce(method, path, opt, dispatch) {
843
+ const maxBytes = capOf(opt);
844
+ const attempt = await send(method, path, opt, dispatch);
526
845
  const res = attempt.res;
527
846
  if (res.status === 401)
528
- throw await unauthorizedOrEdge(attempt, method, path);
847
+ throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
529
848
  if (res.status === 429)
530
- throw await rateLimitedError(attempt, method, path);
849
+ throw await rateLimitedError(attempt, method, path, maxBytes);
531
850
  if (res.status === 204) {
532
851
  discard(attempt);
533
- return undefined;
852
+ return parseJsonBody('', { expect: opt.expect, service, method, path, status: 204 });
534
853
  }
535
- const text = await readBody(attempt, (r) => r.text());
536
854
  if (!res.ok) {
537
- throw httpError(res, text, method, path, service);
855
+ throw httpError(res, await readErrorText(attempt, maxBytes), method, path, service);
538
856
  }
539
- if (text.length === 0)
540
- return undefined;
541
- return JSON.parse(text);
857
+ const text = await readText(attempt, maxBytes);
858
+ return parseJsonBody(text, {
859
+ expect: opt.expect,
860
+ service,
861
+ method,
862
+ path,
863
+ status: res.status,
864
+ headers: res.headers,
865
+ });
542
866
  }
543
- async function fetchHtml(method, path, opt = {}) {
867
+ async function fetchHtmlOnce(method, path, opt, dispatch) {
868
+ const maxBytes = capOf(opt);
544
869
  const headers = { Accept: 'text/html,*/*', ...opt.headers };
545
- const attempt = await send(method, path, { ...opt, headers });
870
+ const attempt = await send(method, path, { ...opt, headers }, dispatch);
546
871
  const res = attempt.res;
547
872
  if (res.status === 401)
548
- throw await unauthorizedOrEdge(attempt, method, path);
873
+ throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
549
874
  if (res.status === 429)
550
- throw await rateLimitedError(attempt, method, path);
551
- const text = await readBody(attempt, (r) => r.text());
875
+ throw await rateLimitedError(attempt, method, path, maxBytes);
552
876
  if (!res.ok) {
553
- throw httpError(res, text, method, path, service);
877
+ throw httpError(res, await readErrorText(attempt, maxBytes), method, path, service);
554
878
  }
555
- return text;
879
+ return readText(attempt, maxBytes);
556
880
  }
557
- async function fetchRaw(method, path, opt = {}) {
881
+ async function fetchRawOnce(method, path, opt, dispatch) {
882
+ const maxBytes = capOf(opt);
558
883
  const headers = { Accept: '*/*', ...opt.headers };
559
- const attempt = await send(method, path, { ...opt, headers });
884
+ const attempt = await send(method, path, { ...opt, headers }, dispatch);
560
885
  const res = attempt.res;
561
886
  if (res.status === 401)
562
- throw await unauthorizedOrEdge(attempt, method, path);
887
+ throw await unauthorizedOrEdge(attempt, method, path, maxBytes);
563
888
  if (res.status === 429)
564
- throw await rateLimitedError(attempt, method, path);
889
+ throw await rateLimitedError(attempt, method, path, maxBytes);
565
890
  if (!res.ok) {
566
- const text = await readBody(attempt, (r) => r.text()).catch((err) => {
891
+ const text = await readText(attempt, maxBytes).catch((err) => {
567
892
  if (err instanceof RequestTimeoutError)
568
893
  throw err;
569
894
  return '';
570
895
  });
571
896
  throw httpError(res, text, method, path, service);
572
897
  }
573
- const bytes = new Uint8Array(await readBody(attempt, (r) => r.arrayBuffer()));
898
+ const bytes = await readBytes(attempt, maxBytes);
574
899
  return {
575
900
  status: res.status,
576
901
  contentType: responseHeader(res, 'content-type'),
@@ -580,6 +905,18 @@ export function createApiClient(opts) {
580
905
  }
581
906
  return { fetchJson, fetchHtml, fetchRaw };
582
907
  }
908
+ /**
909
+ * Validate a byte cap: `undefined` passes through; anything but a
910
+ * non-negative number throws, since `NaN` would silently mean "no cap".
911
+ */
912
+ function checkMaxBytes(name, value) {
913
+ if (value === undefined)
914
+ return undefined;
915
+ if (typeof value !== 'number' || Number.isNaN(value) || value < 0) {
916
+ throw new TypeError(`createApiClient: ${name} must be a non-negative number, got ${String(value)}.`);
917
+ }
918
+ return value === Infinity ? undefined : value;
919
+ }
583
920
  // ---------------------------------------------------------------------------
584
921
  // buildQueryString
585
922
  // ---------------------------------------------------------------------------
@@ -783,6 +1120,45 @@ function setCookieEntries(source) {
783
1120
  const joined = source.get('set-cookie');
784
1121
  return joined ? splitSetCookie(joined) : [];
785
1122
  }
1123
+ /**
1124
+ * Apply a response's `Set-Cookie`s to a caller-owned name→value jar, in place,
1125
+ * and report whether the jar changed (so the caller knows when to persist it).
1126
+ *
1127
+ * - A deletion marker — `Max-Age <= 0`, an `Expires` before 2000 (comma- or
1128
+ * dash-format epoch), or an **empty value** — removes the name. Unlike
1129
+ * {@link CookieJar.absorb}, an empty value is a delete here, so a cleared
1130
+ * session cookie never lingers in a persisted jar as `name=`.
1131
+ * - Any other entry sets the name (later entries win).
1132
+ * - Entries with no name (`=v`, `noequals`) are ignored.
1133
+ *
1134
+ * Returns `true` only when a value was added, replaced with a different value,
1135
+ * or removed; re-setting an identical value or deleting an absent name is
1136
+ * `false`. Accepts the same sources as {@link CookieJar.absorb}: a `Headers`
1137
+ * object (prefers `getSetCookie()`), the `getSetCookie()` array, or a single
1138
+ * comma-joined string (split safely around `Expires` commas).
1139
+ *
1140
+ * Consolidates the hand-rolled `getSetCookie` absorb loops in artsonia,
1141
+ * canvas-parent, crowntowncompost, evite, infinitecampus, myatriumhealth, ofw
1142
+ * and simplepractice (myatriumhealth's stored deletions as empty values).
1143
+ */
1144
+ export function mergeSetCookies(jar, setCookie) {
1145
+ let changed = false;
1146
+ for (const entry of setCookieEntries(setCookie)) {
1147
+ const pair = parseNameValue(entry);
1148
+ if (!pair)
1149
+ continue;
1150
+ if (!pair.value || isDeletionCookie(entry)) {
1151
+ if (jar.delete(pair.name))
1152
+ changed = true;
1153
+ continue;
1154
+ }
1155
+ if (jar.get(pair.name) !== pair.value) {
1156
+ jar.set(pair.name, pair.value);
1157
+ changed = true;
1158
+ }
1159
+ }
1160
+ return changed;
1161
+ }
786
1162
  /**
787
1163
  * Stateful cookie jar for multi-step session logins (login page → CSRF prime →
788
1164
  * credential POST → API calls), built on the {@link parseCookieJar} semantics.