@chrischall/mcp-utils 0.6.0 → 0.8.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.
@@ -16,12 +16,20 @@
16
16
  * - {@link createApiClient} never embeds the token in a thrown message; a 401
17
17
  * yields a fixed "unauthorized" string, not the credential.
18
18
  */
19
- /** Retry policy for transient (HTTP 429) responses. */
19
+ export * from './throttle.js';
20
+ /** Retry policy for transient responses (HTTP 429 by default). */
20
21
  export interface RetryPolicy {
21
22
  /** Number of retries after the initial attempt. The fleet default is 1. */
22
23
  count: number;
23
24
  /** Delay before each retry, in milliseconds. The fleet default is 2000. */
24
25
  delayMs: number;
26
+ /**
27
+ * Statuses that trigger a retry. Defaults to `[429]` — the historical
28
+ * fleet-wide behavior. Add transient 5xx codes (e.g. `[429, 500, 502, 503,
29
+ * 504]`) for upstreams that flake; an exhausted non-429 retried status
30
+ * surfaces through the normal non-2xx path (an {@link ApiError}).
31
+ */
32
+ statuses?: number[];
25
33
  }
26
34
  /** Options for {@link createApiClient}. */
27
35
  /**
@@ -46,6 +54,16 @@ export interface ApiClientOptions {
46
54
  * Optional when {@link ApiClientOptions.tokenManager} is provided.
47
55
  */
48
56
  getToken?: () => string | undefined | Promise<string | undefined>;
57
+ /**
58
+ * Send the token in this named request header instead of
59
+ * `Authorization: Bearer <token>`. The raw token is sent verbatim — no
60
+ * `Bearer ` prefix (e.g. `tokenHeader: 'x-goog-api-key'` /
61
+ * `'x-auth-token'` / `'x-api-key'`). Unset keeps the default
62
+ * `Authorization: Bearer` behavior. Applies to both
63
+ * {@link ApiClientOptions.getToken} and
64
+ * {@link ApiClientOptions.tokenManager} tokens.
65
+ */
66
+ tokenHeader?: string;
49
67
  /**
50
68
  * A reactive token source (e.g. `TokenManager`). When set, every request is
51
69
  * routed through {@link ReactiveTokenSource.withAuth} — proactive refresh +
@@ -130,6 +148,35 @@ export declare class RequestTimeoutError extends Error {
130
148
  readonly timeoutMs: number;
131
149
  constructor(service: string, timeoutMs: number);
132
150
  }
151
+ /**
152
+ * Thrown by {@link ApiClient.fetchJson} / {@link ApiClient.fetchHtml} for a
153
+ * non-2xx response that isn't a 401/429 (those map to
154
+ * {@link UnauthorizedError} / {@link RateLimitedError}). The message is exactly
155
+ * the redacted, truncated {@link formatApiError} string the client has always
156
+ * thrown — this class only adds the `status` so callers can branch
157
+ * (`err instanceof ApiError && err.status === 404`) instead of regexing the
158
+ * message.
159
+ */
160
+ export declare class ApiError extends Error {
161
+ readonly status: number;
162
+ constructor(status: number, message: string);
163
+ }
164
+ /**
165
+ * A status-carrying HTTP error consumers can `throw new` directly — the
166
+ * directly-constructible parallel to {@link ApiError} (which is only thrown
167
+ * *inside* {@link createApiClient}). Extends {@link ApiError} so the same
168
+ * `err instanceof ApiError && err.status === 404` branch works for both, while
169
+ * its own name/`instanceof UpstreamHttpError` distinguishes the manual throws.
170
+ *
171
+ * Consolidates opentable's local `HttpError` and musescore's
172
+ * `MusescoreHttpError` — structural twins (status-carrying, thrown from a
173
+ * transport/bridge code path that doesn't go through `createApiClient`, used to
174
+ * branch on a 404 fallback). Repos that DO route through `createApiClient`
175
+ * already get {@link ApiError} for free and don't need this.
176
+ */
177
+ export declare class UpstreamHttpError extends ApiError {
178
+ constructor(status: number, message: string);
179
+ }
133
180
  /**
134
181
  * Build a bearer-auth fetch client with one-shot 429 retry, 401 mapping, 204 /
135
182
  * empty-body handling, and redacted error formatting.
@@ -198,15 +245,19 @@ export interface ParsedLinkHeader {
198
245
  * Consolidates canvas's pagination Link parser.
199
246
  */
200
247
  export declare function parseLinkHeader(header: string | null | undefined): ParsedLinkHeader;
201
- /** A parsed Set-Cookie jar: deduplicated name→value plus a ready `Cookie` header. */
202
- export interface CookieJar {
248
+ /**
249
+ * A parsed Set-Cookie jar: deduplicated name→value plus a ready `Cookie`
250
+ * header. (Renamed from `CookieJar` to free that name for the stateful
251
+ * {@link CookieJar} class; no fleet consumer imported the type.)
252
+ */
253
+ export interface ParsedCookieJar {
203
254
  /** Surviving cookies, name → value (last value wins, deletions removed). */
204
255
  cookies: Record<string, string>;
205
256
  /** Pre-joined `name=value; name2=value2` string for the `Cookie` request header. */
206
257
  cookieHeader: string;
207
258
  }
208
259
  /**
209
- * Parse `Set-Cookie` headers into a deduplicated {@link CookieJar}.
260
+ * Parse `Set-Cookie` headers into a deduplicated {@link ParsedCookieJar}.
210
261
  *
211
262
  * Login responses commonly send deletion markers (`Max-Age=0` or an epoch
212
263
  * `Expires`) alongside real cookies; forwarding both the delete and set form of
@@ -221,7 +272,62 @@ export interface CookieJar {
221
272
  *
222
273
  * Consolidates the IC/signupgenius cookie-jar logic.
223
274
  */
224
- export declare function parseCookieJar(setCookieHeaders: string[] | string | null | undefined): CookieJar;
275
+ export declare function parseCookieJar(setCookieHeaders: string[] | string | null | undefined): ParsedCookieJar;
276
+ /**
277
+ * Parse a *request* `Cookie:` header (`name=value; name2=value2`) into a
278
+ * name→value map. This is the inbound counterpart to {@link parseCookieJar},
279
+ * which parses *response* `Set-Cookie` headers (with their attributes and
280
+ * deletion semantics) — a `Cookie` header has no attributes, just pairs.
281
+ *
282
+ * Semantics (matching creditkarma's hand-rolled `extractCookieValue` ×3):
283
+ * - splits on `;`, then on the FIRST `=` only, so a value containing `=`
284
+ * (e.g. base64 padding `ab==`, or `x=y=z`) is preserved verbatim,
285
+ * - trims surrounding whitespace from both name and value,
286
+ * - skips fragments with no `=` (bare attributes) or an empty name (`=v`),
287
+ * - keeps an empty value when the name is present (`a=` → `{ a: '' }`),
288
+ * - on a duplicate name, the last value wins.
289
+ *
290
+ * A missing / empty / whitespace-only header yields `{}`.
291
+ *
292
+ * Consolidates creditkarma's `extractCookieValue` (single-name lookup ×3) and
293
+ * the cookie-header pattern-matching in two other repos: `extractCookieValue(h,
294
+ * name)` is `parseCookieHeader(h)[name] ?? null`.
295
+ */
296
+ export declare function parseCookieHeader(header: string): Record<string, string>;
297
+ /**
298
+ * Anything {@link CookieJar.absorb} can read `Set-Cookie`s from: the array from
299
+ * `Headers.getSetCookie()`, a single (possibly comma-joined) header string, or
300
+ * a `Headers`-like object (prefers `getSetCookie()`, falls back to splitting
301
+ * `get('set-cookie')` safely around `Expires` commas).
302
+ */
303
+ export type SetCookieSource = string[] | string | null | undefined | {
304
+ getSetCookie?: () => string[];
305
+ get(name: string): string | null;
306
+ };
307
+ /**
308
+ * Stateful cookie jar for multi-step session logins (login page → CSRF prime →
309
+ * credential POST → API calls), built on the {@link parseCookieJar} semantics.
310
+ *
311
+ * Consolidates the five drifted hand-rolled jars across
312
+ * artsonia/canvas-parent/evite/signupgenius/skylight:
313
+ * - `absorb` merges each response's `Set-Cookie`s into the jar — later values
314
+ * override earlier ones by name, and deletion markers (`Max-Age <= 0` or a
315
+ * pre-2000 `Expires`, both comma- and dash-format epochs) **remove** the
316
+ * name from the jar,
317
+ * - empty-value non-deletion cookies are ignored (an existing value survives),
318
+ * - `header()` renders the `Cookie` request-header value in insertion order.
319
+ */
320
+ export declare class CookieJar {
321
+ private readonly jar;
322
+ /** Merge `Set-Cookie`s into the jar (later wins; deletion markers remove). */
323
+ absorb(setCookies: SetCookieSource): void;
324
+ /** The current value of a cookie, or `undefined` when absent/deleted. */
325
+ get(name: string): string | undefined;
326
+ /** Render the `Cookie` request-header value: `name=value; name2=value2`. */
327
+ header(): string;
328
+ /** Number of cookies currently in the jar. */
329
+ get size(): number;
330
+ }
225
331
  /**
226
332
  * Decode a JWT's `exp` claim (seconds since epoch). Throws when the structure is
227
333
  * invalid or `exp` is missing/non-numeric — the strict variant zola uses for the
@@ -235,6 +341,17 @@ export declare function decodeJwtExp(token: string): number;
235
341
  * header.
236
342
  */
237
343
  export declare function decodeJwtSessionId(token: string): string | null;
344
+ /**
345
+ * Generic single-claim extractor for a JWT payload. Returns the raw claim
346
+ * value (`unknown` — cast or narrow at the call site), or `undefined` for an
347
+ * undecodable token or an absent claim. Never throws.
348
+ *
349
+ * Generalizes the hand-rolled per-claim readers across the fleet (e.g.
350
+ * creditkarma's `extractGlidFromJwt`): `decodeJwtClaim(token, 'glid')` instead
351
+ * of a bespoke decode + payload-field pluck. Reuses the same base64url payload
352
+ * decode as {@link decodeJwtExp} / {@link decodeJwtSessionId}.
353
+ */
354
+ export declare function decodeJwtClaim(token: string, claim: string): unknown;
238
355
  /** Result of {@link validateJwtExpiry}. */
239
356
  export interface JwtExpiryStatus {
240
357
  /** True when the token is past its `exp` (or undecodable / lacks `exp`). */
@@ -254,4 +371,61 @@ export interface JwtExpiryStatus {
254
371
  * reporting `expired: false`, so callers can refresh proactively.
255
372
  */
256
373
  export declare function validateJwtExpiry(token: string, nowMs?: number): JwtExpiryStatus;
374
+ /** Options for {@link runBoundedBatch}. */
375
+ export interface RunBoundedBatchOptions<T, R> {
376
+ /**
377
+ * Overall hard deadline for the WHOLE batch, in milliseconds. When it fires,
378
+ * any item that hasn't settled is filled by {@link onTimeout} and the batch
379
+ * returns immediately — unsettled workers are abandoned (left to settle in
380
+ * the background, never awaited), so a permanently-hung item can't wedge the
381
+ * call past this bound.
382
+ */
383
+ deadlineMs: number;
384
+ /**
385
+ * Backfill for an item the deadline cut off before it settled. Receives the
386
+ * original `item` and its `index` so the placeholder stays identifiable and
387
+ * re-runnable (the zillow `pending`-row pattern). Called only for unsettled
388
+ * slots.
389
+ */
390
+ onTimeout: (item: T, index: number) => R;
391
+ /**
392
+ * Backfill for an item whose `worker` REJECTED (threw). A worker error is
393
+ * isolated to its own slot — it never rejects the batch or cascades onto
394
+ * other items. Defaults to `onTimeout(item, index)` (a rejected worker is
395
+ * treated like a timeout) so a caller that doesn't care needn't distinguish;
396
+ * supply `onError` to tell a genuine failure apart from a deadline cutoff.
397
+ */
398
+ onError?: (item: T, index: number, err: unknown) => R;
399
+ /**
400
+ * Max workers in flight at once. Defaults to unbounded (all items started
401
+ * immediately). Use it to bound fan-out against a rate-limited upstream.
402
+ */
403
+ concurrency?: number;
404
+ /**
405
+ * Injectable timer (for tests). Defaults to `setTimeout`. Must invoke `cb`
406
+ * after roughly `ms`, returning a handle passed to {@link clearTimer}.
407
+ */
408
+ setTimer?: (ms: number, cb: () => void) => unknown;
409
+ /** Injectable clear paired with {@link setTimer}. Defaults to `clearTimeout`. */
410
+ clearTimer?: (handle: unknown) => void;
411
+ }
412
+ /**
413
+ * Run `worker` over `items` with an overall hard deadline and a per-item
414
+ * timeout-backfill, returning a full-length, input-ordered array with exactly
415
+ * one result per item.
416
+ *
417
+ * Each item gets an index-addressable slot. Work is raced against `deadlineMs`;
418
+ * when the deadline fires first, every still-unsettled slot is filled by
419
+ * `onTimeout(item, index)` and the batch resolves immediately — the in-flight
420
+ * workers are abandoned (and signalled via their `AbortSignal`) rather than
421
+ * awaited, so a single hung row can't keep the whole call (and the MCP request
422
+ * deadline behind it) pinned open. When everything settles before the deadline,
423
+ * the timer is cleared and every slot holds its real result.
424
+ *
425
+ * Generalises zillow's bulk-tool `runWithDeadline` (`zillow_bulk_get` /
426
+ * `zillow_resolve_addresses`, issues #98/#78): the slot-array + overall-deadline
427
+ * + `pending`-backfill shape, now with the worker + concurrency folded in and an
428
+ * injectable timer for deterministic tests.
429
+ */
430
+ export declare function runBoundedBatch<T, R>(items: T[], worker: (item: T, signal?: AbortSignal) => Promise<R>, opts: RunBoundedBatchOptions<T, R>): Promise<R[]>;
257
431
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http/index.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/http/index.ts"],"names":[],"mappings":"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"}
@@ -17,6 +17,7 @@
17
17
  * yields a fixed "unauthorized" string, not the credential.
18
18
  */
19
19
  import { truncateErrorMessage } from '../errors/index.js';
20
+ export * from './throttle.js';
20
21
  const DEFAULT_RETRY = { count: 1, delayMs: 2000 };
21
22
  const defaultSleep = (ms) => new Promise((r) => setTimeout(r, ms));
22
23
  /** Thrown for an upstream 401. Carries the status so callers can trigger a re-auth. */
@@ -47,6 +48,44 @@ export class RequestTimeoutError extends Error {
47
48
  Object.setPrototypeOf(this, new.target.prototype);
48
49
  }
49
50
  }
51
+ /**
52
+ * Thrown by {@link ApiClient.fetchJson} / {@link ApiClient.fetchHtml} for a
53
+ * non-2xx response that isn't a 401/429 (those map to
54
+ * {@link UnauthorizedError} / {@link RateLimitedError}). The message is exactly
55
+ * the redacted, truncated {@link formatApiError} string the client has always
56
+ * thrown — this class only adds the `status` so callers can branch
57
+ * (`err instanceof ApiError && err.status === 404`) instead of regexing the
58
+ * message.
59
+ */
60
+ export class ApiError extends Error {
61
+ status;
62
+ constructor(status, message) {
63
+ super(message);
64
+ this.name = 'ApiError';
65
+ this.status = status;
66
+ Object.setPrototypeOf(this, new.target.prototype);
67
+ }
68
+ }
69
+ /**
70
+ * A status-carrying HTTP error consumers can `throw new` directly — the
71
+ * directly-constructible parallel to {@link ApiError} (which is only thrown
72
+ * *inside* {@link createApiClient}). Extends {@link ApiError} so the same
73
+ * `err instanceof ApiError && err.status === 404` branch works for both, while
74
+ * its own name/`instanceof UpstreamHttpError` distinguishes the manual throws.
75
+ *
76
+ * Consolidates opentable's local `HttpError` and musescore's
77
+ * `MusescoreHttpError` — structural twins (status-carrying, thrown from a
78
+ * transport/bridge code path that doesn't go through `createApiClient`, used to
79
+ * branch on a 404 fallback). Repos that DO route through `createApiClient`
80
+ * already get {@link ApiError} for free and don't need this.
81
+ */
82
+ export class UpstreamHttpError extends ApiError {
83
+ constructor(status, message) {
84
+ super(status, message);
85
+ this.name = 'UpstreamHttpError';
86
+ Object.setPrototypeOf(this, new.target.prototype);
87
+ }
88
+ }
50
89
  function hostOf(baseUrl) {
51
90
  try {
52
91
  return new URL(baseUrl).host;
@@ -74,7 +113,11 @@ export function createApiClient(opts) {
74
113
  const sleep = opts.sleep ?? defaultSleep;
75
114
  const unauthorized = () => (opts.onUnauthorized ? opts.onUnauthorized() : new UnauthorizedError(service));
76
115
  const rateLimited = () => (opts.onRateLimited ? opts.onRateLimited() : new RateLimitedError(service));
116
+ const retryStatuses = retry.statuses ?? [429];
77
117
  const timeoutMs = opts.timeout;
118
+ // Default: `Authorization: Bearer <token>`. With `tokenHeader`, the raw
119
+ // token goes in that named header instead (no `Bearer ` prefix).
120
+ const authHeader = (token) => !token ? {} : opts.tokenHeader ? { [opts.tokenHeader]: token } : { Authorization: `Bearer ${token}` };
78
121
  // Bound `run` with an AbortController when a timeout is configured, mapping the
79
122
  // abort to a RequestTimeoutError. No timeout → run as-is (no signal).
80
123
  function withTimeout(run) {
@@ -106,7 +149,7 @@ export function createApiClient(opts) {
106
149
  Accept: 'application/json',
107
150
  ...(hasJsonBody ? { 'Content-Type': 'application/json' } : {}),
108
151
  ...opts.baseHeaders,
109
- ...(token ? { Authorization: `Bearer ${token}` } : {}),
152
+ ...authHeader(token || undefined),
110
153
  ...opt.headers,
111
154
  },
112
155
  ...(signal ? { signal } : {}),
@@ -122,7 +165,7 @@ export function createApiClient(opts) {
122
165
  // attempt 0 is the initial request; up to `retry.count` further attempts on 429.
123
166
  for (;;) {
124
167
  const res = await once();
125
- if (res.status === 429 && attempt < retry.count) {
168
+ if (retryStatuses.includes(res.status) && attempt < retry.count) {
126
169
  attempt += 1;
127
170
  await sleep(retry.delayMs);
128
171
  continue;
@@ -140,7 +183,7 @@ export function createApiClient(opts) {
140
183
  return undefined;
141
184
  const text = await res.text();
142
185
  if (!res.ok) {
143
- throw new Error(formatApiError(res.status, method, path, text, { service }));
186
+ throw new ApiError(res.status, formatApiError(res.status, method, path, text, { service }));
144
187
  }
145
188
  if (text.length === 0)
146
189
  return undefined;
@@ -155,7 +198,7 @@ export function createApiClient(opts) {
155
198
  throw rateLimited();
156
199
  const text = await res.text();
157
200
  if (!res.ok) {
158
- throw new Error(formatApiError(res.status, method, path, text, { service }));
201
+ throw new ApiError(res.status, formatApiError(res.status, method, path, text, { service }));
159
202
  }
160
203
  return text;
161
204
  }
@@ -247,10 +290,41 @@ export function parseLinkHeader(header) {
247
290
  }
248
291
  return out;
249
292
  }
250
- const MAX_AGE_ZERO_RE = /(?:^|;)\s*Max-Age\s*=\s*0\s*(?:;|$)/i;
251
- const EXPIRES_EPOCH_RE = /(?:^|;)\s*Expires\s*=\s*Thu,\s*01\s*Jan\s*1970/i;
293
+ const MAX_AGE_RE = /(?:^|;)\s*Max-Age\s*=\s*(-?\d+)\s*(?:;|$)/i;
294
+ const EXPIRES_RE = /(?:^|;)\s*Expires\s*=\s*([^;]+)/i;
295
+ /** `Expires` dates before this year are treated as deletion markers. Fixed (not
296
+ * `Date.now()`-relative) so parsing stays deterministic; real session cookies
297
+ * are never legitimately set to expire in the previous millennium. */
298
+ const EXPIRES_DELETION_YEAR = 2000;
299
+ /**
300
+ * True when a `Set-Cookie` entry is a deletion marker: `Max-Age <= 0`
301
+ * (including the common `Max-Age=-1`), or — when no `Max-Age` is present
302
+ * (RFC 6265 §5.3: `Max-Age` outranks `Expires`) — an `Expires` date that
303
+ * parses to before {@link EXPIRES_DELETION_YEAR}. Both RFC 1123
304
+ * (`Thu, 01 Jan 1970`) and the legacy dash format (`Thu, 01-Jan-1970`) parse.
305
+ */
306
+ function isDeletionCookie(entry) {
307
+ const maxAge = MAX_AGE_RE.exec(entry);
308
+ if (maxAge)
309
+ return Number(maxAge[1]) <= 0;
310
+ const expires = EXPIRES_RE.exec(entry);
311
+ if (expires && expires[1]) {
312
+ const when = new Date(expires[1].trim());
313
+ if (!Number.isNaN(when.getTime()))
314
+ return when.getUTCFullYear() < EXPIRES_DELETION_YEAR;
315
+ }
316
+ return false;
317
+ }
318
+ /** Parse the leading `name=value` pair of a `Set-Cookie` entry (attrs ignored). */
319
+ function parseNameValue(entry) {
320
+ const nameValue = (entry.split(';')[0] ?? '').trim();
321
+ const eqIdx = nameValue.indexOf('=');
322
+ if (eqIdx < 1)
323
+ return null;
324
+ return { name: nameValue.slice(0, eqIdx).trim(), value: nameValue.slice(eqIdx + 1).trim() };
325
+ }
252
326
  /**
253
- * Parse `Set-Cookie` headers into a deduplicated {@link CookieJar}.
327
+ * Parse `Set-Cookie` headers into a deduplicated {@link ParsedCookieJar}.
254
328
  *
255
329
  * Login responses commonly send deletion markers (`Max-Age=0` or an epoch
256
330
  * `Expires`) alongside real cookies; forwarding both the delete and set form of
@@ -266,24 +340,15 @@ const EXPIRES_EPOCH_RE = /(?:^|;)\s*Expires\s*=\s*Thu,\s*01\s*Jan\s*1970/i;
266
340
  * Consolidates the IC/signupgenius cookie-jar logic.
267
341
  */
268
342
  export function parseCookieJar(setCookieHeaders) {
269
- const entries = setCookieHeaders == null
270
- ? []
271
- : Array.isArray(setCookieHeaders)
272
- ? setCookieHeaders
273
- : splitSetCookie(setCookieHeaders);
343
+ const entries = setCookieEntries(setCookieHeaders);
274
344
  const jar = new Map();
275
345
  for (const entry of entries) {
276
- if (MAX_AGE_ZERO_RE.test(entry) || EXPIRES_EPOCH_RE.test(entry))
346
+ if (isDeletionCookie(entry))
277
347
  continue;
278
- const nameValue = (entry.split(';')[0] ?? '').trim();
279
- const eqIdx = nameValue.indexOf('=');
280
- if (eqIdx < 1)
348
+ const pair = parseNameValue(entry);
349
+ if (!pair || !pair.value)
281
350
  continue;
282
- const name = nameValue.slice(0, eqIdx).trim();
283
- const value = nameValue.slice(eqIdx + 1).trim();
284
- if (!value)
285
- continue;
286
- jar.set(name, value);
351
+ jar.set(pair.name, pair.value);
287
352
  }
288
353
  const cookies = {};
289
354
  for (const [k, v] of jar)
@@ -291,6 +356,100 @@ export function parseCookieJar(setCookieHeaders) {
291
356
  const cookieHeader = [...jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
292
357
  return { cookies, cookieHeader };
293
358
  }
359
+ // ---------------------------------------------------------------------------
360
+ // parseCookieHeader
361
+ // ---------------------------------------------------------------------------
362
+ /**
363
+ * Parse a *request* `Cookie:` header (`name=value; name2=value2`) into a
364
+ * name→value map. This is the inbound counterpart to {@link parseCookieJar},
365
+ * which parses *response* `Set-Cookie` headers (with their attributes and
366
+ * deletion semantics) — a `Cookie` header has no attributes, just pairs.
367
+ *
368
+ * Semantics (matching creditkarma's hand-rolled `extractCookieValue` ×3):
369
+ * - splits on `;`, then on the FIRST `=` only, so a value containing `=`
370
+ * (e.g. base64 padding `ab==`, or `x=y=z`) is preserved verbatim,
371
+ * - trims surrounding whitespace from both name and value,
372
+ * - skips fragments with no `=` (bare attributes) or an empty name (`=v`),
373
+ * - keeps an empty value when the name is present (`a=` → `{ a: '' }`),
374
+ * - on a duplicate name, the last value wins.
375
+ *
376
+ * A missing / empty / whitespace-only header yields `{}`.
377
+ *
378
+ * Consolidates creditkarma's `extractCookieValue` (single-name lookup ×3) and
379
+ * the cookie-header pattern-matching in two other repos: `extractCookieValue(h,
380
+ * name)` is `parseCookieHeader(h)[name] ?? null`.
381
+ */
382
+ export function parseCookieHeader(header) {
383
+ const out = {};
384
+ if (!header)
385
+ return out;
386
+ for (const part of header.split(';')) {
387
+ const eqIdx = part.indexOf('=');
388
+ if (eqIdx < 0)
389
+ continue; // bare attribute fragment, no `=`
390
+ const name = part.slice(0, eqIdx).trim();
391
+ if (name === '')
392
+ continue; // missing name (leading `=`)
393
+ out[name] = part.slice(eqIdx + 1).trim();
394
+ }
395
+ return out;
396
+ }
397
+ /** Normalize a {@link SetCookieSource} to individual `Set-Cookie` strings. */
398
+ function setCookieEntries(source) {
399
+ if (source == null)
400
+ return [];
401
+ if (Array.isArray(source))
402
+ return source;
403
+ if (typeof source === 'string')
404
+ return splitSetCookie(source);
405
+ if (typeof source.getSetCookie === 'function')
406
+ return source.getSetCookie();
407
+ const joined = source.get('set-cookie');
408
+ return joined ? splitSetCookie(joined) : [];
409
+ }
410
+ /**
411
+ * Stateful cookie jar for multi-step session logins (login page → CSRF prime →
412
+ * credential POST → API calls), built on the {@link parseCookieJar} semantics.
413
+ *
414
+ * Consolidates the five drifted hand-rolled jars across
415
+ * artsonia/canvas-parent/evite/signupgenius/skylight:
416
+ * - `absorb` merges each response's `Set-Cookie`s into the jar — later values
417
+ * override earlier ones by name, and deletion markers (`Max-Age <= 0` or a
418
+ * pre-2000 `Expires`, both comma- and dash-format epochs) **remove** the
419
+ * name from the jar,
420
+ * - empty-value non-deletion cookies are ignored (an existing value survives),
421
+ * - `header()` renders the `Cookie` request-header value in insertion order.
422
+ */
423
+ export class CookieJar {
424
+ jar = new Map();
425
+ /** Merge `Set-Cookie`s into the jar (later wins; deletion markers remove). */
426
+ absorb(setCookies) {
427
+ for (const entry of setCookieEntries(setCookies)) {
428
+ const pair = parseNameValue(entry);
429
+ if (!pair)
430
+ continue;
431
+ if (isDeletionCookie(entry)) {
432
+ this.jar.delete(pair.name);
433
+ continue;
434
+ }
435
+ if (!pair.value)
436
+ continue;
437
+ this.jar.set(pair.name, pair.value);
438
+ }
439
+ }
440
+ /** The current value of a cookie, or `undefined` when absent/deleted. */
441
+ get(name) {
442
+ return this.jar.get(name);
443
+ }
444
+ /** Render the `Cookie` request-header value: `name=value; name2=value2`. */
445
+ header() {
446
+ return [...this.jar.entries()].map(([k, v]) => `${k}=${v}`).join('; ');
447
+ }
448
+ /** Number of cookies currently in the jar. */
449
+ get size() {
450
+ return this.jar.size;
451
+ }
452
+ }
294
453
  /** Best-effort split of a single joined `set-cookie` string (no `getSetCookie()`). */
295
454
  function splitSetCookie(header) {
296
455
  // Split on commas that are NOT part of an Expires date ("Thu, 01 Jan ...").
@@ -349,6 +508,22 @@ export function decodeJwtSessionId(token) {
349
508
  const sid = payload['session_id'] ?? payload['sid'];
350
509
  return typeof sid === 'string' ? sid : null;
351
510
  }
511
+ /**
512
+ * Generic single-claim extractor for a JWT payload. Returns the raw claim
513
+ * value (`unknown` — cast or narrow at the call site), or `undefined` for an
514
+ * undecodable token or an absent claim. Never throws.
515
+ *
516
+ * Generalizes the hand-rolled per-claim readers across the fleet (e.g.
517
+ * creditkarma's `extractGlidFromJwt`): `decodeJwtClaim(token, 'glid')` instead
518
+ * of a bespoke decode + payload-field pluck. Reuses the same base64url payload
519
+ * decode as {@link decodeJwtExp} / {@link decodeJwtSessionId}.
520
+ */
521
+ export function decodeJwtClaim(token, claim) {
522
+ const payload = decodeJwtPayload(token);
523
+ if (!payload)
524
+ return undefined;
525
+ return payload[claim];
526
+ }
352
527
  /** Tokens within this many seconds of `exp` are flagged as near-expiry. */
353
528
  const NEAR_EXPIRY_SKEW_SEC = 300;
354
529
  /**
@@ -380,4 +555,88 @@ export function validateJwtExpiry(token, nowMs = Date.now()) {
380
555
  }
381
556
  return { expired: false, expiresIn };
382
557
  }
558
+ /**
559
+ * Run `worker` over `items` with an overall hard deadline and a per-item
560
+ * timeout-backfill, returning a full-length, input-ordered array with exactly
561
+ * one result per item.
562
+ *
563
+ * Each item gets an index-addressable slot. Work is raced against `deadlineMs`;
564
+ * when the deadline fires first, every still-unsettled slot is filled by
565
+ * `onTimeout(item, index)` and the batch resolves immediately — the in-flight
566
+ * workers are abandoned (and signalled via their `AbortSignal`) rather than
567
+ * awaited, so a single hung row can't keep the whole call (and the MCP request
568
+ * deadline behind it) pinned open. When everything settles before the deadline,
569
+ * the timer is cleared and every slot holds its real result.
570
+ *
571
+ * Generalises zillow's bulk-tool `runWithDeadline` (`zillow_bulk_get` /
572
+ * `zillow_resolve_addresses`, issues #98/#78): the slot-array + overall-deadline
573
+ * + `pending`-backfill shape, now with the worker + concurrency folded in and an
574
+ * injectable timer for deterministic tests.
575
+ */
576
+ export function runBoundedBatch(items, worker, opts) {
577
+ const slots = Array.from({ length: items.length });
578
+ const settled = new Array(items.length).fill(false);
579
+ // An item whose worker threw: backfilled via onError (default onTimeout) at
580
+ // finish, distinct from a slot the deadline simply never reached.
581
+ const errored = new Array(items.length).fill(false);
582
+ const errorVals = new Array(items.length);
583
+ // Nothing to do — never arm a timer for an empty batch.
584
+ if (items.length === 0)
585
+ return Promise.resolve([]);
586
+ const setTimer = opts.setTimer ?? ((ms, cb) => setTimeout(cb, ms));
587
+ const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
588
+ const controller = new AbortController();
589
+ // Bounded fan-out: a pool of `limit` runners pulling the next index off a
590
+ // shared cursor. limit >= items.length (or unset) means "all at once".
591
+ const limit = opts.concurrency && opts.concurrency > 0
592
+ ? Math.min(opts.concurrency, items.length)
593
+ : items.length;
594
+ const runAll = async () => {
595
+ let cursor = 0;
596
+ const runner = async () => {
597
+ for (;;) {
598
+ const index = cursor;
599
+ cursor += 1;
600
+ if (index >= items.length)
601
+ return;
602
+ // Isolate a worker rejection to its own slot: record it and continue
603
+ // pulling the next item, so one bad row neither rejects the batch nor
604
+ // strands the items this runner hasn't reached yet.
605
+ try {
606
+ slots[index] = await worker(items[index], controller.signal);
607
+ settled[index] = true;
608
+ }
609
+ catch (err) {
610
+ errored[index] = true;
611
+ errorVals[index] = err;
612
+ }
613
+ }
614
+ };
615
+ await Promise.all(Array.from({ length: limit }, () => runner()));
616
+ };
617
+ return new Promise((resolve) => {
618
+ let done = false;
619
+ const finish = () => {
620
+ if (done)
621
+ return;
622
+ done = true;
623
+ clearTimer(handle);
624
+ // Signal abandoned workers so a cooperative worker can stop early.
625
+ if (!controller.signal.aborted)
626
+ controller.abort();
627
+ const onError = opts.onError ?? ((item, index) => opts.onTimeout(item, index));
628
+ resolve(items.map((item, index) => {
629
+ if (settled[index])
630
+ return slots[index];
631
+ if (errored[index])
632
+ return onError(item, index, errorVals[index]);
633
+ return opts.onTimeout(item, index);
634
+ }));
635
+ };
636
+ const handle = setTimer(opts.deadlineMs, finish);
637
+ // Abandon (never await) the fan-out on the deadline path; on full settle,
638
+ // finish() clears the timer and returns the real results.
639
+ void runAll().then(finish, finish);
640
+ });
641
+ }
383
642
  //# sourceMappingURL=index.js.map