@chrischall/mcp-utils 2.15.1 → 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.
- package/README.md +493 -26
- package/dist/auth/index.d.ts +25 -1
- package/dist/auth/index.d.ts.map +1 -1
- package/dist/auth/index.js +105 -14
- package/dist/auth/index.js.map +1 -1
- package/dist/config/index.d.ts +13 -0
- package/dist/config/index.d.ts.map +1 -1
- package/dist/config/index.js +3 -2
- package/dist/config/index.js.map +1 -1
- package/dist/dates/index.d.ts +45 -5
- package/dist/dates/index.d.ts.map +1 -1
- package/dist/dates/index.js +64 -6
- package/dist/dates/index.js.map +1 -1
- package/dist/errors/index.d.ts +121 -7
- package/dist/errors/index.d.ts.map +1 -1
- package/dist/errors/index.js +154 -9
- package/dist/errors/index.js.map +1 -1
- package/dist/fs/output.d.ts +25 -8
- package/dist/fs/output.d.ts.map +1 -1
- package/dist/fs/output.js +27 -5
- package/dist/fs/output.js.map +1 -1
- package/dist/graphql/client.d.ts +8 -1
- package/dist/graphql/client.d.ts.map +1 -1
- package/dist/graphql/client.js +11 -3
- package/dist/graphql/client.js.map +1 -1
- package/dist/healthcheck/index.d.ts +11 -9
- package/dist/healthcheck/index.d.ts.map +1 -1
- package/dist/healthcheck/index.js +39 -23
- package/dist/healthcheck/index.js.map +1 -1
- package/dist/http/body-limit.d.ts +37 -0
- package/dist/http/body-limit.d.ts.map +1 -0
- package/dist/http/body-limit.js +86 -0
- package/dist/http/body-limit.js.map +1 -0
- package/dist/http/fetch-bounded.d.ts +91 -0
- package/dist/http/fetch-bounded.d.ts.map +1 -0
- package/dist/http/fetch-bounded.js +138 -0
- package/dist/http/fetch-bounded.js.map +1 -0
- package/dist/http/index.d.ts +231 -6
- package/dist/http/index.d.ts.map +1 -1
- package/dist/http/index.js +432 -56
- package/dist/http/index.js.map +1 -1
- package/dist/http/response-cache.d.ts +36 -3
- package/dist/http/response-cache.d.ts.map +1 -1
- package/dist/http/response-cache.js +84 -8
- package/dist/http/response-cache.js.map +1 -1
- package/dist/http/throttle.d.ts +37 -4
- package/dist/http/throttle.d.ts.map +1 -1
- package/dist/http/throttle.js +65 -6
- package/dist/http/throttle.js.map +1 -1
- package/dist/http/url-safety.d.ts +142 -0
- package/dist/http/url-safety.d.ts.map +1 -0
- package/dist/http/url-safety.js +211 -0
- package/dist/http/url-safety.js.map +1 -0
- package/dist/netguard/index.d.ts +196 -0
- package/dist/netguard/index.d.ts.map +1 -0
- package/dist/netguard/index.js +365 -0
- package/dist/netguard/index.js.map +1 -0
- package/dist/server/confirm-env.d.ts +17 -8
- package/dist/server/confirm-env.d.ts.map +1 -1
- package/dist/server/confirm-env.js +22 -13
- package/dist/server/confirm-env.js.map +1 -1
- package/dist/server/confirm-write.js +1 -1
- package/dist/server/confirm-write.js.map +1 -1
- package/dist/server/index.d.ts +6 -2
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +24 -9
- package/dist/server/index.js.map +1 -1
- package/dist/session/index.d.ts +6 -0
- package/dist/session/index.d.ts.map +1 -1
- package/dist/session/index.js +12 -1
- package/dist/session/index.js.map +1 -1
- package/dist/zod/index.d.ts +6 -0
- package/dist/zod/index.d.ts.map +1 -1
- package/dist/zod/index.js +8 -1
- package/dist/zod/index.js.map +1 -1
- package/package.json +10 -1
package/dist/http/index.js
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
|
|
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
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
525
|
-
|
|
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
|
|
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,
|
|
855
|
+
throw httpError(res, await readErrorText(attempt, maxBytes), method, path, service);
|
|
538
856
|
}
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
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
|
|
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,
|
|
877
|
+
throw httpError(res, await readErrorText(attempt, maxBytes), method, path, service);
|
|
554
878
|
}
|
|
555
|
-
return
|
|
879
|
+
return readText(attempt, maxBytes);
|
|
556
880
|
}
|
|
557
|
-
async function
|
|
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
|
|
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 =
|
|
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.
|