anilink-api-wrapper 2.1.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/AniLink.mjs CHANGED
@@ -1,6 +1,6 @@
1
+ import { randomInt, randomUUID, createHash } from 'node:crypto';
1
2
  import http from 'node:http';
2
3
  import https from 'node:https';
3
- import { randomInt } from 'node:crypto';
4
4
  import axios from 'axios';
5
5
 
6
6
  const AniLinkErrorCodes = {
@@ -23,14 +23,18 @@ class AniLinkError extends Error {
23
23
  * @param message - A safe message intended for application logs.
24
24
  * @param code - The stable code used to classify the failure.
25
25
  * @param rawAxiosError - The original Axios error when raw diagnostics are enabled.
26
+ * @param options - Additional error metadata such as the request correlation ID.
26
27
  */
27
- constructor(message, code, rawAxiosError) {
28
+ constructor(message, code, rawAxiosError, options) {
28
29
  super(message, rawAxiosError instanceof Error ? { cause: rawAxiosError } : void 0);
29
30
  this.name = "AniLinkError";
30
31
  this.code = code;
31
32
  if (rawAxiosError !== void 0) {
32
33
  this.rawAxiosError = rawAxiosError;
33
34
  }
35
+ if (options?.requestId !== void 0) {
36
+ this.requestId = options.requestId;
37
+ }
34
38
  Object.setPrototypeOf(this, new.target.prototype);
35
39
  }
36
40
  }
@@ -45,16 +49,21 @@ class AniLinkApiError extends AniLinkError {
45
49
  * @param status - The HTTP status returned by AniList.
46
50
  * @param data - The response body returned by AniList.
47
51
  * @param rawAxiosError - The original Axios error when raw diagnostics are enabled.
48
- * @param options - Additional error metadata such as rate-limit headers.
52
+ * @param options - Additional error metadata such as rate-limit headers and the response content type.
49
53
  */
50
54
  constructor(status, data, rawAxiosError, options) {
51
- super(`API request failed with status ${status}.`, AniLinkErrorCodes.API, rawAxiosError);
55
+ super(`API request failed with status ${status}.`, AniLinkErrorCodes.API, rawAxiosError, {
56
+ requestId: options?.requestId
57
+ });
52
58
  this.name = "AniLinkApiError";
53
59
  this.status = status;
54
60
  this.data = data;
55
61
  if (options?.rateLimit !== void 0) {
56
62
  this.rateLimit = options.rateLimit;
57
63
  }
64
+ if (options?.contentType !== void 0) {
65
+ this.contentType = options.contentType;
66
+ }
58
67
  }
59
68
  }
60
69
  const extractUpstreamStatus = (errors) => {
@@ -80,9 +89,10 @@ class AniLinkGraphQLError extends AniLinkApiError {
80
89
  * @param errors - The upstream GraphQL errors; each entry should carry a `message`.
81
90
  * @param data - The partial `data` object returned alongside the errors, when any. Exposed as {@link AniLinkGraphQLError.partialData}.
82
91
  * @param rawAxiosError - The original Axios error when raw diagnostics are enabled.
92
+ * @param options - Additional error metadata such as rate-limit headers and the response content type. AniList returns rate-limit headers even on HTTP 200 envelopes carrying GraphQL errors (for example a GraphQL-level `429`), so threading them here keeps {@link AniLinkGraphQLError.rateLimit} consistent with the HTTP-failure path.
83
93
  */
84
- constructor(errors, data, rawAxiosError) {
85
- super(extractUpstreamStatus(errors) ?? 200, data, rawAxiosError);
94
+ constructor(errors, data, rawAxiosError, options) {
95
+ super(extractUpstreamStatus(errors) ?? 200, data, rawAxiosError, options);
86
96
  this.name = "AniLinkGraphQLError";
87
97
  this.code = AniLinkErrorCodes.GRAPHQL;
88
98
  this.message = `The request failed with GraphQL errors: ${errors.map((graphqlError) => graphqlError.message).join("; ")}`;
@@ -131,7 +141,7 @@ class AniLinkRestError extends AniLinkApiError {
131
141
  * @param status - The HTTP status returned by the upstream REST API.
132
142
  * @param data - The response body returned by the upstream REST API.
133
143
  * @param rawAxiosError - The original Axios error when raw diagnostics are enabled.
134
- * @param options - Additional error metadata such as rate-limit headers.
144
+ * @param options - Additional error metadata such as rate-limit headers, the response content type, and the request correlation ID.
135
145
  */
136
146
  constructor(status, data, rawAxiosError, options) {
137
147
  super(status, data, rawAxiosError, options);
@@ -145,29 +155,24 @@ class AniLinkNetworkError extends AniLinkError {
145
155
  * @param code - The stable code for the transport failure.
146
156
  * @param message - A safe message intended for application logs.
147
157
  * @param rawAxiosError - The original Axios error when raw diagnostics are enabled.
148
- * @param options - Additional transport metadata such as the effective timeout duration.
158
+ * @param options - Additional transport metadata such as the effective timeout duration and the request correlation ID.
149
159
  */
150
160
  constructor(code, message, rawAxiosError, options) {
151
- super(message, code, rawAxiosError);
161
+ super(message, code, rawAxiosError, { requestId: options?.requestId });
152
162
  this.name = "AniLinkNetworkError";
153
163
  if (options?.timeoutMs !== void 0) {
154
164
  this.timeoutMs = options.timeoutMs;
155
165
  }
166
+ if (options?.abortedDuringPacing !== void 0) {
167
+ this.abortedDuringPacing = options.abortedDuringPacing;
168
+ }
156
169
  }
157
170
  }
158
171
 
159
172
  const DEFAULT_REQUEST_TIMEOUT = 3e4;
160
- const MAX_RETRY_AFTER_MS = 6e4;
161
173
  const MAX_FREE_SOCKETS = 5;
162
174
  const MAX_SOCKETS = 20;
163
- const DEFAULT_RETRY_POLICY = {
164
- maxRetries: 3,
165
- baseDelayMs: 250,
166
- maxDelayMs: 5e3,
167
- retryOnStatus: [429, 500, 502, 503, 504],
168
- retryOnNetworkError: true,
169
- jitter: true
170
- };
175
+
171
176
  const defaultHttpAgent = new http.Agent({
172
177
  keepAlive: true,
173
178
  maxSockets: MAX_SOCKETS,
@@ -185,32 +190,176 @@ const axiosClient = axios.create({
185
190
  httpAgent: defaultHttpAgent,
186
191
  httpsAgent: defaultHttpsAgent
187
192
  });
188
- const resolveRetryPolicy = (retry) => {
189
- if (retry === false) {
190
- return null;
193
+ const MAX_CACHED_AGENT_PAIRS = 8;
194
+ const cachedAgentPairs = /* @__PURE__ */ new Map();
195
+ const parkedEvictedPairs = [];
196
+ const buildAgentCacheKey = (maxSockets, maxFreeSockets) => `${maxSockets}:${maxFreeSockets}`;
197
+ const evictLruAgentPair = () => {
198
+ const oldestKey = cachedAgentPairs.keys().next().value;
199
+ if (oldestKey !== void 0) {
200
+ const evicted = cachedAgentPairs.get(oldestKey);
201
+ cachedAgentPairs.delete(oldestKey);
202
+ if (evicted !== void 0) {
203
+ parkedEvictedPairs.push(evicted);
204
+ }
191
205
  }
192
- if (retry === void 0 || retry === true) {
193
- return { ...DEFAULT_RETRY_POLICY };
206
+ };
207
+ const destroyCachedAgents = () => {
208
+ for (const pair of cachedAgentPairs.values()) {
209
+ pair.httpAgent.destroy();
210
+ pair.httpsAgent.destroy();
194
211
  }
195
- return { ...DEFAULT_RETRY_POLICY, ...retry };
212
+ cachedAgentPairs.clear();
213
+ for (const pair of parkedEvictedPairs) {
214
+ pair.httpAgent.destroy();
215
+ pair.httpsAgent.destroy();
216
+ }
217
+ parkedEvictedPairs.length = 0;
196
218
  };
197
219
  const resolveAgents = (maxSockets, maxFreeSockets) => {
198
220
  if (maxSockets === void 0 && maxFreeSockets === void 0) {
199
221
  return { httpAgent: defaultHttpAgent, httpsAgent: defaultHttpsAgent };
200
222
  }
201
- const sockets = Math.max(1, maxSockets ?? MAX_SOCKETS);
202
- const freeSockets = Math.max(0, maxFreeSockets ?? MAX_FREE_SOCKETS);
223
+ const normalizeSockets = (value, fallback, min) => {
224
+ if (value === void 0 || !Number.isFinite(value) || !Number.isInteger(value)) {
225
+ return Math.max(min, fallback);
226
+ }
227
+ return Math.max(min, value);
228
+ };
229
+ const sockets = normalizeSockets(maxSockets, MAX_SOCKETS, 1);
230
+ const freeSockets = normalizeSockets(maxFreeSockets, MAX_FREE_SOCKETS, 0);
231
+ const key = buildAgentCacheKey(sockets, freeSockets);
232
+ const cached = cachedAgentPairs.get(key);
233
+ if (cached !== void 0) {
234
+ cachedAgentPairs.delete(key);
235
+ cachedAgentPairs.set(key, cached);
236
+ return { httpAgent: cached.httpAgent, httpsAgent: cached.httpsAgent };
237
+ }
238
+ if (cachedAgentPairs.size >= MAX_CACHED_AGENT_PAIRS) {
239
+ evictLruAgentPair();
240
+ }
203
241
  const agentOptions = {
204
242
  keepAlive: true,
205
243
  maxSockets: sockets,
206
244
  maxFreeSockets: freeSockets,
207
245
  scheduling: "lifo"
208
246
  };
209
- return {
247
+ const pair = {
210
248
  httpAgent: new http.Agent(agentOptions),
211
249
  httpsAgent: new https.Agent(agentOptions)
212
250
  };
251
+ cachedAgentPairs.set(key, pair);
252
+ return { httpAgent: pair.httpAgent, httpsAgent: pair.httpsAgent };
213
253
  };
254
+
255
+ const MAX_RETRY_AFTER_MS = 6e4;
256
+ const DEFAULT_RETRY_POLICY = {
257
+ maxRetries: 3,
258
+ baseDelayMs: 250,
259
+ maxDelayMs: 5e3,
260
+ retryOnStatus: [429, 500, 502, 503, 504],
261
+ retryOnNetworkError: true,
262
+ jitter: true
263
+ };
264
+ const resolveRetryPolicy = (retry) => {
265
+ if (retry === false) {
266
+ return null;
267
+ }
268
+ if (retry === void 0 || retry === true) {
269
+ return { ...DEFAULT_RETRY_POLICY };
270
+ }
271
+ const filtered = {};
272
+ for (const [key, value] of Object.entries(retry)) {
273
+ if (value !== void 0) {
274
+ filtered[key] = value;
275
+ }
276
+ }
277
+ return { ...DEFAULT_RETRY_POLICY, ...filtered };
278
+ };
279
+ const parseRetryAfter = (header, now) => {
280
+ if (header === void 0 || header === null || header === "") {
281
+ return null;
282
+ }
283
+ const seconds = Number(header);
284
+ if (Number.isFinite(seconds) && seconds >= 0) {
285
+ return Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS);
286
+ }
287
+ const date = Date.parse(header);
288
+ if (Number.isFinite(date)) {
289
+ return Math.max(0, Math.min(date - now, MAX_RETRY_AFTER_MS));
290
+ }
291
+ return null;
292
+ };
293
+ const getRetryAfterDelay = (error) => {
294
+ if (axios.isAxiosError(error)) {
295
+ const header = error.response?.headers?.["retry-after"];
296
+ if (typeof header === "string" || header === void 0) {
297
+ return parseRetryAfter(header, Date.now());
298
+ }
299
+ }
300
+ return null;
301
+ };
302
+ const getBackoffDelay = (attempt, policy) => Math.min(policy.baseDelayMs * 2 ** attempt, policy.maxDelayMs);
303
+ const applyJitter = (cap, policy) => policy.jitter === false ? cap : randomInt(0, cap + 1);
304
+ const getRetryDelay = (error, rawError, attempt, policy) => {
305
+ if (attempt >= policy.maxRetries) {
306
+ return null;
307
+ }
308
+ if (error instanceof AniLinkGraphQLError) {
309
+ if (error.status === 429) {
310
+ return getRetryAfterDelay(rawError) ?? applyJitter(getBackoffDelay(attempt, policy), policy);
311
+ }
312
+ if (policy.retryOnStatus.includes(error.status)) {
313
+ return applyJitter(getBackoffDelay(attempt, policy), policy);
314
+ }
315
+ return null;
316
+ }
317
+ if (error instanceof AniLinkApiError) {
318
+ if (error.status === 429) {
319
+ return getRetryAfterDelay(rawError) ?? applyJitter(getBackoffDelay(attempt, policy), policy);
320
+ }
321
+ if (policy.retryOnStatus.includes(error.status)) {
322
+ return applyJitter(getBackoffDelay(attempt, policy), policy);
323
+ }
324
+ return null;
325
+ }
326
+ if (error instanceof AniLinkNetworkError) {
327
+ if (error.code === AniLinkErrorCodes.ABORTED) {
328
+ return null;
329
+ }
330
+ if (policy.retryOnNetworkError) {
331
+ return applyJitter(getBackoffDelay(attempt, policy), policy);
332
+ }
333
+ }
334
+ return null;
335
+ };
336
+ const computeNextRetryDelay = (input) => {
337
+ const { normalized, rawError, attempt, policy, budgetState, budget, wasProbe } = input;
338
+ if (wasProbe || policy === null) {
339
+ return null;
340
+ }
341
+ if (budgetState !== void 0 && budget !== void 0 && budgetState.retriesUsed >= budget.maxRetriesPerWindow) {
342
+ return null;
343
+ }
344
+ return getRetryDelay(normalized, rawError, attempt, policy);
345
+ };
346
+ const retryBudgetStates = /* @__PURE__ */ new WeakMap();
347
+ const getRetryBudgetState = (owner, budget) => {
348
+ if (owner === void 0 || budget === void 0) {
349
+ return void 0;
350
+ }
351
+ let state = retryBudgetStates.get(owner);
352
+ if (state === void 0) {
353
+ state = { retriesUsed: 0, windowEndsAt: 0 };
354
+ retryBudgetStates.set(owner, state);
355
+ }
356
+ if (Date.now() >= state.windowEndsAt) {
357
+ state.retriesUsed = 0;
358
+ state.windowEndsAt = Date.now() + budget.windowMs;
359
+ }
360
+ return state;
361
+ };
362
+
214
363
  const resolveRequestOptions = (options = {}) => {
215
364
  const timeout = options.timeout ?? DEFAULT_REQUEST_TIMEOUT;
216
365
  if (!Number.isFinite(timeout) || timeout < 0) {
@@ -231,29 +380,78 @@ const resolveRequestOptions = (options = {}) => {
231
380
  onError: options.onError,
232
381
  onRetry: options.onRetry,
233
382
  onRequestStart: options.onRequestStart,
234
- onResponse: options.onResponse
383
+ onResponse: options.onResponse,
384
+ onPace: options.onPace,
385
+ onHookError: options.onHookError,
386
+ onCircuitOpen: options.onCircuitOpen,
387
+ onCircuitClose: options.onCircuitClose,
388
+ ignorePaceDeadline: options.ignorePaceDeadline ?? false,
389
+ responseCache: options.responseCache
235
390
  };
236
391
  };
237
- const unwrapSingleRootField = (response) => {
238
- const envelope = response;
239
- const queryData = envelope?.data;
240
- if (!queryData || typeof queryData !== "object") {
241
- return void 0;
392
+
393
+ const SENSITIVE_HEADER_KEYS = /^(authorization|cookie|set-cookie|proxy-authorization|proxy-auth|authentication|(x-)?api[-_]?key|(x-)?auth[-_]?token|(x-)?session(-id)?|session)$/i;
394
+ const redactAxiosError = (error) => {
395
+ const config = error.config;
396
+ const response = error.response;
397
+ const isHeaderMap = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
398
+ const configNeedsRedaction = config !== void 0 && (isHeaderMap(config.headers) || config.data !== void 0 || config.auth !== void 0);
399
+ const responseNeedsRedaction = response !== void 0 && (response.config !== void 0 || isHeaderMap(response.headers) || response.request !== void 0);
400
+ const requestNeedsRedaction = error.request !== void 0;
401
+ if (!configNeedsRedaction && !responseNeedsRedaction && !requestNeedsRedaction) {
402
+ return error;
242
403
  }
243
- const fields = Object.keys(queryData);
244
- if (fields.length === 1) {
245
- return queryData[fields[0]];
404
+ const redactHeaders = (headers) => {
405
+ const scrubbed = {};
406
+ for (const [key, value] of Object.entries(headers)) {
407
+ if (value !== null && typeof value === "object" && !Array.isArray(value)) {
408
+ scrubbed[key] = redactHeaders(value);
409
+ } else {
410
+ scrubbed[key] = SENSITIVE_HEADER_KEYS.test(key) ? "[REDACTED]" : value;
411
+ }
412
+ }
413
+ return scrubbed;
414
+ };
415
+ const redactConfig = (source) => {
416
+ const cloned2 = { ...source };
417
+ const headers = source.headers;
418
+ if (isHeaderMap(headers)) {
419
+ cloned2.headers = redactHeaders(headers);
420
+ }
421
+ if (source.data !== void 0) {
422
+ cloned2.data = "[REDACTED]";
423
+ }
424
+ if (source.auth !== void 0) {
425
+ cloned2.auth = "[REDACTED]";
426
+ }
427
+ return cloned2;
428
+ };
429
+ const cloned = { ...error };
430
+ const clonedRecord = cloned;
431
+ if (requestNeedsRedaction) {
432
+ clonedRecord.request = "[REDACTED]";
246
433
  }
247
- return void 0;
248
- };
249
- const unwrapGraphQLResponse = (response) => {
250
- const envelope = response;
251
- if (Array.isArray(envelope?.errors) && envelope.errors.length > 0) {
252
- throw new AniLinkGraphQLError(envelope.errors, envelope?.data);
434
+ if (config !== void 0) {
435
+ clonedRecord.config = redactConfig(config);
253
436
  }
254
- return unwrapSingleRootField(response) ?? response;
437
+ if (response !== void 0) {
438
+ const clonedResponse = { ...response };
439
+ const responseConfig = response.config;
440
+ if (responseConfig !== void 0) {
441
+ clonedResponse.config = redactConfig(responseConfig);
442
+ }
443
+ const responseHeaders = response.headers;
444
+ if (isHeaderMap(responseHeaders)) {
445
+ clonedResponse.headers = redactHeaders(responseHeaders);
446
+ }
447
+ if (response.request !== void 0) {
448
+ clonedResponse.request = "[REDACTED]";
449
+ }
450
+ clonedRecord.response = clonedResponse;
451
+ }
452
+ return cloned;
255
453
  };
256
- const getRawAxiosError = (resolved, error) => resolved.exposeRawAxiosError ? error : void 0;
454
+ const getRawAxiosError = (resolved, error) => resolved.exposeRawAxiosError ? axios.isAxiosError(error) ? redactAxiosError(error) : error : void 0;
257
455
  const getRateLimitInfo = (headers) => {
258
456
  if (!headers) {
259
457
  return void 0;
@@ -266,19 +464,29 @@ const getRateLimitInfo = (headers) => {
266
464
  }
267
465
  return { limit, remaining, reset };
268
466
  };
269
- const normalizeAxiosError = (resolved, error, isRestCall = false) => {
467
+ const getResponseContentType = (headers) => {
468
+ if (!headers) return void 0;
469
+ const raw = headers["content-type"] ?? headers["Content-Type"];
470
+ return typeof raw === "string" ? raw : void 0;
471
+ };
472
+ const normalizeAxiosError = (resolved, error, isRestCall = false, requestId) => {
270
473
  if (axios.isCancel(error)) {
271
474
  return new AniLinkNetworkError(
272
475
  AniLinkErrorCodes.ABORTED,
273
476
  "The request was cancelled.",
274
- getRawAxiosError(resolved, error)
477
+ getRawAxiosError(resolved, error),
478
+ { requestId }
275
479
  );
276
480
  }
277
481
  if (error.response?.status !== void 0) {
278
482
  const status = error.response.status;
279
483
  const data = error.response.data;
280
484
  const rawAxiosError = getRawAxiosError(resolved, error);
281
- const options = { rateLimit: getRateLimitInfo(error.response.headers) };
485
+ const options = {
486
+ rateLimit: getRateLimitInfo(error.response.headers),
487
+ contentType: getResponseContentType(error.response.headers),
488
+ requestId
489
+ };
282
490
  return isRestCall ? new AniLinkRestError(status, data, rawAxiosError, options) : new AniLinkApiError(status, data, rawAxiosError, options);
283
491
  }
284
492
  if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
@@ -286,128 +494,133 @@ const normalizeAxiosError = (resolved, error, isRestCall = false) => {
286
494
  AniLinkErrorCodes.TIMEOUT,
287
495
  "The request timed out.",
288
496
  getRawAxiosError(resolved, error),
289
- resolved.timeout > 0 ? { timeoutMs: resolved.timeout } : void 0
497
+ { timeoutMs: resolved.timeout > 0 ? resolved.timeout : void 0, requestId }
290
498
  );
291
499
  }
292
500
  return new AniLinkNetworkError(
293
501
  AniLinkErrorCodes.NETWORK,
294
502
  "The request failed due to a network error.",
295
- getRawAxiosError(resolved, error)
503
+ getRawAxiosError(resolved, error),
504
+ { requestId }
296
505
  );
297
506
  };
298
- const normalizeRequestError = (resolved, error, isRestCall = false) => {
507
+ const stampRequestId = (error, requestId) => {
508
+ if (requestId === void 0 || error.requestId !== void 0) {
509
+ return;
510
+ }
511
+ Object.defineProperty(error, "requestId", {
512
+ value: requestId,
513
+ writable: false,
514
+ enumerable: true,
515
+ configurable: false
516
+ });
517
+ };
518
+ const normalizeRequestError = (resolved, error, isRestCall = false, requestId) => {
299
519
  if (error instanceof AniLinkError) {
520
+ stampRequestId(error, requestId);
300
521
  return error;
301
522
  }
302
523
  if (axios.isAxiosError(error)) {
303
- return normalizeAxiosError(resolved, error, isRestCall);
524
+ return normalizeAxiosError(resolved, error, isRestCall, requestId);
304
525
  }
305
526
  return new AniLinkError(
306
527
  "The request failed.",
307
528
  AniLinkErrorCodes.UNKNOWN,
308
- getRawAxiosError(resolved, error)
529
+ getRawAxiosError(resolved, error),
530
+ { requestId }
309
531
  );
310
532
  };
311
- const parseRetryAfter = (header, now) => {
312
- if (header === void 0 || header === null || header === "") {
313
- return null;
314
- }
315
- const seconds = Number(header);
316
- if (Number.isFinite(seconds) && seconds >= 0) {
317
- return Math.min(seconds * 1e3, MAX_RETRY_AFTER_MS);
318
- }
319
- const date = Date.parse(header);
320
- if (Number.isFinite(date)) {
321
- return Math.max(0, Math.min(date - now, MAX_RETRY_AFTER_MS));
533
+
534
+ const unwrapSingleRootField = (response) => {
535
+ const envelope = response;
536
+ const queryData = envelope?.data;
537
+ if (!queryData || typeof queryData !== "object") {
538
+ return void 0;
322
539
  }
323
- return null;
324
- };
325
- const getRetryAfterDelay = (error) => {
326
- if (axios.isAxiosError(error)) {
327
- const header = error.response?.headers?.["retry-after"];
328
- if (typeof header === "string" || header === void 0) {
329
- return parseRetryAfter(header, Date.now());
330
- }
540
+ const fields = Object.keys(queryData);
541
+ if (fields.length === 1) {
542
+ return queryData[fields[0]];
331
543
  }
332
- return null;
544
+ return void 0;
333
545
  };
334
- const getBackoffDelay = (attempt, policy) => Math.min(policy.baseDelayMs * 2 ** attempt, policy.maxDelayMs);
335
- const applyJitter = (cap, policy) => policy.jitter === false ? cap : randomInt(0, cap + 1);
336
- const getRetryDelay = (error, rawError, attempt, policy) => {
337
- if (attempt >= policy.maxRetries) {
338
- return null;
339
- }
340
- if (error instanceof AniLinkApiError) {
341
- if (error.status === 429) {
342
- return getRetryAfterDelay(rawError) ?? applyJitter(getBackoffDelay(attempt, policy), policy);
343
- }
344
- if (policy.retryOnStatus.includes(error.status)) {
345
- return applyJitter(getBackoffDelay(attempt, policy), policy);
346
- }
347
- return null;
348
- }
349
- if (error instanceof AniLinkNetworkError) {
350
- if (error.code === AniLinkErrorCodes.ABORTED) {
351
- return null;
352
- }
353
- if (policy.retryOnNetworkError) {
354
- return applyJitter(getBackoffDelay(attempt, policy), policy);
355
- }
546
+ const unwrapGraphQLResponse = (response, headers) => {
547
+ const envelope = response;
548
+ if (Array.isArray(envelope?.errors) && envelope.errors.length > 0) {
549
+ throw new AniLinkGraphQLError(envelope.errors, envelope?.data, void 0, {
550
+ rateLimit: getRateLimitInfo(headers),
551
+ contentType: getResponseContentType(headers ?? {})
552
+ });
356
553
  }
357
- return null;
554
+ const unwrapped = unwrapSingleRootField(response);
555
+ return unwrapped === void 0 ? response : unwrapped;
358
556
  };
359
- const sleep = (ms, signal) => new Promise((resolve, reject) => {
360
- const timeout = setTimeout(() => {
361
- signal?.removeEventListener("abort", abort);
362
- resolve();
363
- }, ms);
364
- const abort = () => {
365
- clearTimeout(timeout);
366
- reject(
367
- new AniLinkNetworkError(AniLinkErrorCodes.ABORTED, "The request was cancelled.")
368
- );
369
- };
370
- if (signal?.aborted) {
371
- abort();
372
- return;
373
- }
374
- signal?.addEventListener("abort", abort, { once: true });
375
- });
376
- const safeInvoke = (hook, name, ...args) => {
557
+
558
+ const safeInvoke = (hook, name, onHookError, ...args) => {
377
559
  if (hook === void 0) {
378
560
  return;
379
561
  }
380
562
  try {
381
563
  hook(...args);
382
564
  } catch (hookError) {
565
+ if (onHookError !== void 0) {
566
+ try {
567
+ onHookError(name, hookError);
568
+ } catch {
569
+ }
570
+ return;
571
+ }
572
+ const firstArg = args[0];
573
+ const requestId = firstArg !== null && typeof firstArg === "object" && "requestId" in firstArg && typeof firstArg.requestId === "string" ? firstArg.requestId : void 0;
574
+ const correlation = requestId === void 0 ? "" : ` (requestId: ${requestId})`;
383
575
  console.warn(
384
- `[AniLink] ${name} hook threw and was ignored:`,
576
+ `[AniLink] ${name} hook threw and was ignored${correlation}:`,
385
577
  hookError instanceof Error ? hookError.message : hookError
386
578
  );
387
579
  }
388
580
  };
389
- const circuitStates = /* @__PURE__ */ new WeakMap();
390
- const retryBudgetStates = /* @__PURE__ */ new WeakMap();
391
- const getRetryBudgetState = (owner, budget) => {
392
- if (owner === void 0 || budget === void 0) {
393
- return void 0;
581
+ const buildErrorContext = (requestId, url, method, attempt, normalized, nextDelayMs) => ({
582
+ requestId,
583
+ url,
584
+ method,
585
+ attempt,
586
+ code: normalized.code,
587
+ ...normalized instanceof AniLinkApiError ? { status: normalized.status } : {},
588
+ ...normalized instanceof AniLinkApiError && normalized.rateLimit !== void 0 ? { rateLimit: normalized.rateLimit } : {},
589
+ ...nextDelayMs === void 0 ? {} : { nextDelayMs }
590
+ });
591
+ const reportFailure = (requestId, url, method, attempt, normalized, resolved, nextDelayMs) => {
592
+ const context = buildErrorContext(requestId, url, method, attempt, normalized, nextDelayMs);
593
+ if (nextDelayMs !== void 0) {
594
+ safeInvoke(
595
+ resolved.onRetry ?? resolved.onError,
596
+ resolved.onRetry === void 0 ? "onError" : "onRetry",
597
+ resolved.onHookError,
598
+ normalized,
599
+ context
600
+ );
601
+ return;
394
602
  }
395
- let state = retryBudgetStates.get(owner);
396
- if (state === void 0) {
397
- state = { retriesUsed: 0, windowEndsAt: 0 };
398
- retryBudgetStates.set(owner, state);
603
+ safeInvoke(resolved.onError, "onError", resolved.onHookError, normalized, context);
604
+ };
605
+
606
+ const circuitStates = /* @__PURE__ */ new WeakMap();
607
+ const isAvailabilityFailure = (error) => {
608
+ if (error instanceof AniLinkNetworkError) {
609
+ return error.code !== AniLinkErrorCodes.ABORTED;
399
610
  }
400
- if (Date.now() >= state.windowEndsAt) {
401
- state.retriesUsed = 0;
402
- state.windowEndsAt = Date.now() + budget.windowMs;
611
+ if (error instanceof AniLinkApiError) {
612
+ return error.status === 429 || error.status >= 500;
403
613
  }
404
- return state;
614
+ return false;
405
615
  };
406
- const circuitScopeOf = (url) => {
616
+ const MAX_CIRCUIT_SCOPES_PER_OWNER = 64;
617
+ const circuitScopeOf = (url, requestId) => {
407
618
  try {
408
619
  return new URL(url).host;
409
620
  } catch {
410
- return "";
621
+ const error = new AniLinkValidationError(["Unparseable request URL"]);
622
+ stampRequestId(error, requestId);
623
+ throw error;
411
624
  }
412
625
  };
413
626
  const getCircuitState = (owner, scope) => {
@@ -417,67 +630,178 @@ const getCircuitState = (owner, scope) => {
417
630
  circuitStates.set(owner, scopes);
418
631
  }
419
632
  let state = scopes.get(scope);
420
- if (state === void 0) {
421
- state = { consecutiveFailures: 0, openedAt: null };
633
+ if (state !== void 0) {
634
+ scopes.delete(scope);
422
635
  scopes.set(scope, state);
636
+ return state;
423
637
  }
638
+ if (scopes.size >= MAX_CIRCUIT_SCOPES_PER_OWNER) {
639
+ const oldestScope = scopes.keys().next().value;
640
+ if (oldestScope !== void 0) {
641
+ scopes.delete(oldestScope);
642
+ }
643
+ }
644
+ state = {
645
+ consecutiveFailures: 0,
646
+ openedAt: null,
647
+ probeInFlight: false
648
+ };
649
+ scopes.set(scope, state);
424
650
  return state;
425
651
  };
426
- const throwIfCircuitOpen = (circuit, breaker) => {
427
- if (circuit === void 0 || breaker === void 0 || circuit.openedAt === null) {
428
- return;
652
+ const checkCircuitOpen = (circuit, breaker) => {
653
+ if (circuit === void 0 || breaker === void 0) {
654
+ return void 0;
655
+ }
656
+ if (circuit.probeInFlight) {
657
+ return new AniLinkNetworkError(
658
+ AniLinkErrorCodes.CIRCUIT,
659
+ `The request failed fast: the circuit breaker is probing the upstream after ${breaker.threshold} consecutive failures. Retrying is possible once the probe settles.`
660
+ );
661
+ }
662
+ if (circuit.openedAt === null) {
663
+ return void 0;
429
664
  }
430
665
  if (Date.now() - circuit.openedAt < breaker.cooldownMs) {
431
- throw new AniLinkNetworkError(
666
+ return new AniLinkNetworkError(
432
667
  AniLinkErrorCodes.CIRCUIT,
433
668
  `The request failed fast: the circuit breaker is open after ${breaker.threshold} consecutive failures. Retrying is possible after the cooldown elapses.`
434
669
  );
435
670
  }
436
671
  circuit.openedAt = null;
672
+ circuit.probeInFlight = true;
673
+ return void 0;
437
674
  };
438
- const recordCircuitSuccess = (circuit) => {
439
- if (circuit !== void 0) {
440
- circuit.consecutiveFailures = 0;
675
+ const recordCircuitSuccess = (circuit, resolved, hookContext, host) => {
676
+ if (circuit === void 0) {
677
+ return;
678
+ }
679
+ const wasOpen = circuit.openedAt !== null || circuit.probeInFlight;
680
+ circuit.probeInFlight = false;
681
+ circuit.consecutiveFailures = 0;
682
+ circuit.openedAt = null;
683
+ if (wasOpen && resolved.onCircuitClose !== void 0) {
684
+ safeInvoke(resolved.onCircuitClose, "onCircuitClose", resolved.onHookError, {
685
+ ...hookContext,
686
+ host
687
+ });
441
688
  }
442
689
  };
443
- const recordCircuitFailure = (circuit, breaker) => {
690
+ const recordCircuitFailure = (circuit, breaker, normalized, resolved, hookContext, host) => {
444
691
  if (circuit === void 0 || breaker === void 0) {
445
692
  return;
446
693
  }
694
+ if (!isAvailabilityFailure(normalized)) {
695
+ recordCircuitSuccess(circuit, resolved, hookContext, host);
696
+ return;
697
+ }
698
+ if (circuit.probeInFlight) {
699
+ circuit.probeInFlight = false;
700
+ circuit.openedAt = Date.now();
701
+ return;
702
+ }
447
703
  circuit.consecutiveFailures += 1;
448
- if (circuit.consecutiveFailures >= breaker.threshold) {
704
+ if (circuit.consecutiveFailures >= breaker.threshold && circuit.openedAt === null) {
449
705
  circuit.openedAt = Date.now();
706
+ if (resolved.onCircuitOpen !== void 0) {
707
+ safeInvoke(resolved.onCircuitOpen, "onCircuitOpen", resolved.onHookError, {
708
+ ...hookContext,
709
+ host,
710
+ failures: circuit.consecutiveFailures
711
+ });
712
+ }
450
713
  }
451
714
  };
452
- const buildErrorContext = (url, method, attempt, normalized, nextDelayMs) => ({
453
- url,
454
- method,
455
- attempt,
456
- code: normalized.code,
457
- ...normalized instanceof AniLinkApiError ? { status: normalized.status } : {},
458
- ...nextDelayMs === void 0 ? {} : { nextDelayMs }
715
+
716
+ const sleep = (ms, signal, requestId) => new Promise((resolve, reject) => {
717
+ const timeout = setTimeout(() => {
718
+ signal?.removeEventListener("abort", abort);
719
+ resolve();
720
+ }, ms);
721
+ timeout.unref();
722
+ const abort = () => {
723
+ clearTimeout(timeout);
724
+ const error = new AniLinkNetworkError(
725
+ AniLinkErrorCodes.ABORTED,
726
+ "The request was cancelled."
727
+ );
728
+ stampRequestId(error, requestId);
729
+ reject(error);
730
+ };
731
+ if (signal?.aborted) {
732
+ abort();
733
+ return;
734
+ }
735
+ signal?.addEventListener("abort", abort, { once: true });
459
736
  });
460
- const paceAfterSuccess = async (response, resolved) => {
461
- if (!resolved.paceWithRateLimit) {
737
+
738
+ const paceDeadlines = /* @__PURE__ */ new WeakMap();
739
+ const MAX_PACE_WAIT_MS = 5 * 60 * 1e3;
740
+ const sleepForPacing = async (delayMs, resolved, hookContext) => {
741
+ try {
742
+ await sleep(delayMs, resolved.signal, hookContext.requestId);
743
+ } catch (error) {
744
+ if (error instanceof AniLinkNetworkError && error.code === AniLinkErrorCodes.ABORTED && !axios.isCancel(error)) {
745
+ const pacingError = new AniLinkNetworkError(
746
+ AniLinkErrorCodes.ABORTED,
747
+ "The request was cancelled while waiting for the rate-limit window to reset.",
748
+ void 0,
749
+ { abortedDuringPacing: true }
750
+ );
751
+ stampRequestId(pacingError, hookContext.requestId);
752
+ throw pacingError;
753
+ }
754
+ throw error;
755
+ }
756
+ };
757
+ const recordPaceDeadline = (owner, host, deadlineMs) => {
758
+ let scopes = paceDeadlines.get(owner);
759
+ if (scopes === void 0) {
760
+ scopes = /* @__PURE__ */ new Map();
761
+ paceDeadlines.set(owner, scopes);
762
+ }
763
+ if (deadlineMs <= Date.now()) {
764
+ scopes.delete(host);
462
765
  return;
463
766
  }
464
- const info = getRateLimitInfo(response.headers);
465
- if (info !== void 0 && info.remaining < resolved.rateLimitFloor) {
466
- await sleep(Math.max(0, info.reset * 1e3 - Date.now()), resolved.signal);
767
+ const existing = scopes.get(host);
768
+ if (existing !== void 0 && deadlineMs <= existing) {
769
+ return;
467
770
  }
771
+ scopes.set(host, deadlineMs);
468
772
  };
469
- const reportFailure = (url, method, attempt, normalized, resolved, nextDelayMs) => {
470
- const context = buildErrorContext(url, method, attempt, normalized, nextDelayMs);
471
- if (nextDelayMs !== void 0) {
472
- safeInvoke(
473
- resolved.onRetry ?? resolved.onError,
474
- resolved.onRetry === void 0 ? "onError" : "onRetry",
475
- normalized,
476
- context
477
- );
773
+ const awaitPaceDeadline = async (owner, host, resolved, hookContext) => {
774
+ if (owner === void 0 || !resolved.paceWithRateLimit || resolved.ignorePaceDeadline) {
775
+ return;
776
+ }
777
+ const deadlineMs = paceDeadlines.get(owner)?.get(host);
778
+ if (deadlineMs === void 0) {
779
+ return;
780
+ }
781
+ const delayMs = deadlineMs - Date.now();
782
+ if (delayMs <= 0) {
783
+ paceDeadlines.get(owner)?.delete(host);
784
+ return;
785
+ }
786
+ safeInvoke(resolved.onPace, "onPace", resolved.onHookError, { ...hookContext, delayMs });
787
+ await sleepForPacing(delayMs, resolved, hookContext);
788
+ };
789
+ const paceAfterSuccess = async (response, resolved, hookContext, rateLimit, owner, host) => {
790
+ if (!resolved.paceWithRateLimit) {
478
791
  return;
479
792
  }
480
- safeInvoke(resolved.onError, "onError", normalized, context);
793
+ const info = rateLimit ?? getRateLimitInfo(response.headers);
794
+ if (info !== void 0 && info.remaining < resolved.rateLimitFloor) {
795
+ const rawDeadlineMs = info.reset * 1e3;
796
+ const now = Date.now();
797
+ const deadlineMs = Math.min(rawDeadlineMs, now + MAX_PACE_WAIT_MS);
798
+ const delayMs = Math.max(0, deadlineMs - now);
799
+ if (owner !== void 0 && host !== void 0) {
800
+ recordPaceDeadline(owner, host, deadlineMs);
801
+ }
802
+ safeInvoke(resolved.onPace, "onPace", resolved.onHookError, { ...hookContext, delayMs });
803
+ await sleepForPacing(delayMs, resolved, hookContext);
804
+ }
481
805
  };
482
806
  const isPacingAbort = (resolved, error) => resolved.paceWithRateLimit && error instanceof AniLinkNetworkError && error.code === AniLinkErrorCodes.ABORTED && !axios.isCancel(error);
483
807
  const rethrowIfPacingAbort = (resolved, error) => {
@@ -485,17 +809,46 @@ const rethrowIfPacingAbort = (resolved, error) => {
485
809
  throw error;
486
810
  }
487
811
  };
488
- const executeWithRetry = async (options, resolved, stateKey, rawPassthrough = false) => {
812
+
813
+ const executeWithRetry = async (options, resolved, stateOwner, rawPassthrough = false) => {
489
814
  const { url, method, data, headers } = options;
490
815
  const policy = resolved.retry;
491
- const circuit = resolved.circuitBreaker !== void 0 && stateKey !== void 0 ? getCircuitState(stateKey, circuitScopeOf(url)) : void 0;
492
- const budgetState = getRetryBudgetState(stateKey, resolved.retryBudget);
816
+ const requestId = randomUUID();
817
+ const host = circuitScopeOf(url, requestId);
818
+ const circuit = resolved.circuitBreaker !== void 0 && stateOwner !== void 0 ? getCircuitState(stateOwner, host) : void 0;
819
+ const budgetState = getRetryBudgetState(stateOwner, resolved.retryBudget);
493
820
  let attempt = 0;
494
821
  for (; ; ) {
495
- throwIfCircuitOpen(circuit, resolved.circuitBreaker);
496
822
  const startedAt = Date.now();
497
- const hookContext = { url, method, attempt: attempt + 1 };
498
- safeInvoke(resolved.onRequestStart, "onRequestStart", hookContext);
823
+ const hookContext = { requestId, url, method, attempt: attempt + 1 };
824
+ const circuitError = checkCircuitOpen(circuit, resolved.circuitBreaker);
825
+ if (circuitError !== void 0) {
826
+ safeInvoke(
827
+ resolved.onRequestStart,
828
+ "onRequestStart",
829
+ resolved.onHookError,
830
+ hookContext
831
+ );
832
+ stampRequestId(circuitError, requestId);
833
+ safeInvoke(
834
+ resolved.onError,
835
+ "onError",
836
+ resolved.onHookError,
837
+ circuitError,
838
+ buildErrorContext(requestId, url, method, attempt + 1, circuitError)
839
+ );
840
+ throw circuitError;
841
+ }
842
+ try {
843
+ await awaitPaceDeadline(stateOwner, host, resolved, hookContext);
844
+ } catch (paceError) {
845
+ if (circuit !== void 0 && circuit.probeInFlight) {
846
+ recordCircuitSuccess(circuit, resolved, hookContext, host);
847
+ }
848
+ throw paceError;
849
+ }
850
+ safeInvoke(resolved.onRequestStart, "onRequestStart", resolved.onHookError, hookContext);
851
+ let responseReported = false;
499
852
  try {
500
853
  const response = await axiosClient({
501
854
  url,
@@ -507,41 +860,111 @@ const executeWithRetry = async (options, resolved, stateKey, rawPassthrough = fa
507
860
  httpAgent: resolved.httpAgent,
508
861
  httpsAgent: resolved.httpsAgent
509
862
  });
510
- safeInvoke(resolved.onResponse, "onResponse", {
863
+ const rateLimit = getRateLimitInfo(response.headers);
864
+ safeInvoke(resolved.onResponse, "onResponse", resolved.onHookError, {
511
865
  ...hookContext,
512
- durationMs: Date.now() - startedAt
866
+ durationMs: Date.now() - startedAt,
867
+ ...rateLimit !== void 0 ? { rateLimit } : {}
513
868
  });
514
- recordCircuitSuccess(circuit);
515
- await paceAfterSuccess(response, resolved);
516
- return rawPassthrough ? response.data : unwrapGraphQLResponse(response.data);
869
+ responseReported = true;
870
+ const result = rawPassthrough ? response.data : unwrapGraphQLResponse(
871
+ response.data,
872
+ response.headers
873
+ );
874
+ recordCircuitSuccess(circuit, resolved, hookContext, host);
875
+ await paceAfterSuccess(response, resolved, hookContext, rateLimit, stateOwner, host);
876
+ return result;
517
877
  } catch (error) {
518
878
  rethrowIfPacingAbort(resolved, error);
519
- safeInvoke(resolved.onResponse, "onResponse", {
520
- ...hookContext,
521
- durationMs: Date.now() - startedAt
879
+ if (!responseReported) {
880
+ safeInvoke(resolved.onResponse, "onResponse", resolved.onHookError, {
881
+ ...hookContext,
882
+ durationMs: Date.now() - startedAt
883
+ });
884
+ }
885
+ const normalized = normalizeRequestError(resolved, error, rawPassthrough, requestId);
886
+ const wasProbe = circuit?.probeInFlight === true;
887
+ recordCircuitFailure(
888
+ circuit,
889
+ resolved.circuitBreaker,
890
+ normalized,
891
+ resolved,
892
+ hookContext,
893
+ host
894
+ );
895
+ const delay = computeNextRetryDelay({
896
+ normalized,
897
+ rawError: error,
898
+ attempt,
899
+ policy,
900
+ budgetState,
901
+ budget: resolved.retryBudget,
902
+ wasProbe
522
903
  });
523
- const normalized = normalizeRequestError(resolved, error, rawPassthrough);
524
- recordCircuitFailure(circuit, resolved.circuitBreaker);
525
- const delay = policy === null || budgetState === void 0 ? policy === null ? null : getRetryDelay(normalized, error, attempt, policy) : budgetState.retriesUsed >= resolved.retryBudget.maxRetriesPerWindow ? null : getRetryDelay(normalized, error, attempt, policy);
526
904
  if (delay !== null && budgetState !== void 0) {
527
905
  budgetState.retriesUsed += 1;
528
906
  }
529
- reportFailure(url, method, attempt + 1, normalized, resolved, delay ?? void 0);
907
+ reportFailure(
908
+ requestId,
909
+ url,
910
+ method,
911
+ attempt + 1,
912
+ normalized,
913
+ resolved,
914
+ delay ?? void 0
915
+ );
530
916
  if (delay === null) {
531
917
  throw normalized;
532
918
  }
533
919
  attempt += 1;
534
- await sleep(delay, resolved.signal);
920
+ await sleep(delay, resolved.signal, requestId);
921
+ }
922
+ }
923
+ };
924
+ const buildAuthCacheKey = (token) => token === void 0 ? "none" : `bearer:${createHash("sha256").update(token).digest("hex").slice(0, 16)}`;
925
+ const buildCacheAuthKey = (hasBearerToken, hasCredentialHeaders, token) => {
926
+ if (hasCredentialHeaders) {
927
+ return void 0;
928
+ }
929
+ if (hasBearerToken) {
930
+ return buildAuthCacheKey(token);
931
+ }
932
+ return buildAuthCacheKey(void 0);
933
+ };
934
+ let warnedOptionsKeyedState = false;
935
+ const warnOptionsKeyedState = (onHookError) => {
936
+ if (warnedOptionsKeyedState) {
937
+ return;
938
+ }
939
+ warnedOptionsKeyedState = true;
940
+ const message = "[AniLink] circuit-breaker/retry-budget state is keyed by the per-request options object because no stateOwner was passed. Pass a stable stateOwner (or reuse one options object across calls) so failure streaks accumulate.";
941
+ if (onHookError !== void 0) {
942
+ try {
943
+ onHookError("stateOwner", new Error(message));
944
+ } catch {
535
945
  }
946
+ return;
536
947
  }
948
+ console.warn(message);
537
949
  };
538
- const sendRequest = async (url, method, data, auth, ...requestOptions) => {
539
- const [requiresAuth = false, options, operation, contentType] = requestOptions;
950
+ const sendRequest = async (url, method, data, auth, sendOptions) => {
951
+ const {
952
+ requiresAuth = false,
953
+ options,
954
+ operation,
955
+ protocol,
956
+ contentType,
957
+ stateOwner
958
+ } = sendOptions ?? {};
959
+ const isRestCall = protocol === "rest" || protocol === void 0 && contentType !== void 0;
540
960
  const resolvedAuth = typeof auth === "string" ? { token: auth } : auth;
541
961
  const hasBearerToken = resolvedAuth?.token !== void 0 && resolvedAuth.token !== "";
542
962
  const hasAuthorizationHeader = Object.entries(resolvedAuth?.headers ?? {}).some(
543
963
  ([key, value]) => key.toLowerCase() === "authorization" && value !== ""
544
964
  );
965
+ const hasCredentialHeaders = Object.entries(resolvedAuth?.headers ?? {}).some(
966
+ ([, value]) => value !== ""
967
+ );
545
968
  const hasAuthMaterial = hasBearerToken || hasAuthorizationHeader;
546
969
  if (requiresAuth && !hasAuthMaterial) {
547
970
  throw new AniLinkAuthError(operation);
@@ -554,12 +977,41 @@ const sendRequest = async (url, method, data, auth, ...requestOptions) => {
554
977
  if (hasBearerToken && !hasAuthorizationHeader) {
555
978
  headers.Authorization = `Bearer ${resolvedAuth.token}`;
556
979
  }
980
+ const resolved = resolveRequestOptions(options);
981
+ if (stateOwner === void 0 && options !== void 0 && (resolved.circuitBreaker !== void 0 || resolved.retryBudget !== void 0)) {
982
+ warnOptionsKeyedState(resolved.onHookError);
983
+ }
984
+ const cacheEnabled = resolved.responseCache !== void 0 && method === "GET";
985
+ const cacheAuthKey = cacheEnabled ? buildCacheAuthKey(hasBearerToken, hasCredentialHeaders, resolvedAuth?.token) : void 0;
986
+ const cacheActive = cacheEnabled && cacheAuthKey !== void 0;
987
+ if (cacheActive) {
988
+ const cached = resolved.responseCache.get(method, url, data, cacheAuthKey);
989
+ if (cached !== void 0) {
990
+ const requestId = randomUUID();
991
+ const hookContext = { requestId, url, method, attempt: 1 };
992
+ safeInvoke(
993
+ resolved.onRequestStart,
994
+ "onRequestStart",
995
+ resolved.onHookError,
996
+ hookContext
997
+ );
998
+ safeInvoke(resolved.onResponse, "onResponse", resolved.onHookError, {
999
+ ...hookContext,
1000
+ durationMs: 0,
1001
+ cacheHit: true
1002
+ });
1003
+ return cached;
1004
+ }
1005
+ }
557
1006
  const result = await executeWithRetry(
558
1007
  { url, method, data, headers },
559
- resolveRequestOptions(options),
560
- options,
561
- contentType !== void 0
1008
+ resolved,
1009
+ stateOwner ?? options,
1010
+ isRestCall
562
1011
  );
1012
+ if (cacheActive) {
1013
+ resolved.responseCache.set(method, url, data, cacheAuthKey, result);
1014
+ }
563
1015
  return result;
564
1016
  };
565
1017
 
@@ -573,6 +1025,13 @@ const resolveOperationLabel = (operation) => {
573
1025
  return typeof name === "string" && name.length > 0 ? name : void 0;
574
1026
  };
575
1027
  class BaseOperation {
1028
+ /**
1029
+ * Stable per-instance object keying cross-request transport state (the
1030
+ * circuit breaker and retry budget). One per instance so failure streaks
1031
+ * accumulate across every request this operation dispatches, regardless
1032
+ * of per-request option objects.
1033
+ */
1034
+ stateOwner = {};
576
1035
  /**
577
1036
  * The authentication token shared by all operations of an instance.
578
1037
  */
@@ -626,25 +1085,24 @@ class BaseOperation {
626
1085
  * @typeParam T - The parsed response type returned verbatim by the pipeline.
627
1086
  * @param url - The absolute endpoint URL to call.
628
1087
  * @param method - The HTTP method for the call.
629
- * @param data - The request body payload, when the call carries one.
630
- * @param requiresAuth - Whether the operation requires an authentication token.
631
- * @param operation - Human-readable operation name included in missing-token auth errors. Defaults to the concrete subclass name.
632
- * @param transportOptions - Optional per-request transport settings merged over the instance-level ones. A field set here wins; unset fields keep the instance value.
633
- * @param contentType - Optional `Content-Type` override. When provided, the response body is returned verbatim instead of being unwrapped as a GraphQL envelope.
1088
+ * @param data - The request body payload, when the call carries one: a JSON-serializable object, or a pre-encoded string body (for example form-urlencoded OAuth grants).
1089
+ * @param options - Named trailing options; see {@link DispatchOptions}.
634
1090
  * @returns Whatever the shared pipeline resolves for the call.
635
1091
  * @throws An {@link AniLinkAuthError} when `requiresAuth` is true and no token is set, or a normalized {@link AniLinkError} when the request fails.
636
1092
  */
637
- async dispatch(url, method, data, requiresAuth = false, operation, transportOptions, contentType) {
638
- return await sendRequest(
639
- url,
640
- method,
641
- data,
642
- this.requestAuth,
643
- requiresAuth || void 0,
644
- mergeOptions(this.resolvedOptions, transportOptions),
645
- operation ?? resolveOperationLabel(this),
646
- contentType
647
- );
1093
+ async dispatch(url, method, data, options = {}) {
1094
+ const { requiresAuth, operation, transportOptions, contentType, protocol } = options;
1095
+ return await sendRequest(url, method, data, this.requestAuth, {
1096
+ requiresAuth: requiresAuth || void 0,
1097
+ options: mergeOptions(this.resolvedOptions, transportOptions),
1098
+ operation: operation ?? resolveOperationLabel(this),
1099
+ contentType,
1100
+ protocol,
1101
+ // The instance keys cross-request transport state (circuit
1102
+ // breaker, retry budget) so failure streaks accumulate across
1103
+ // requests even when each call carries fresh per-request options.
1104
+ stateOwner: this.stateOwner
1105
+ });
648
1106
  }
649
1107
  }
650
1108
 
@@ -653,12 +1111,42 @@ const isPrimitive = (mapping) => typeof mapping === "string" && PRIMITIVES.inclu
653
1111
  const isArrayType = (mapping) => typeof mapping === "string" && mapping.endsWith("[]");
654
1112
  const isAllowlist = (mapping) => Array.isArray(mapping);
655
1113
  const isObjectMapping = (mapping) => typeof mapping === "object" && mapping !== null && !Array.isArray(mapping);
656
- const describeValue = (value) => {
1114
+ const SENSITIVE_KEY_PATTERN = /token|secret|password|authorization|cookie|credential|api[-_]?key|pass|session|otp|bearer/i;
1115
+ const isPlainObject$1 = (value) => {
1116
+ const proto = Object.getPrototypeOf(value);
1117
+ return proto === Object.prototype || proto === null;
1118
+ };
1119
+ const redactValue = (key, value) => {
1120
+ if (SENSITIVE_KEY_PATTERN.test(key)) {
1121
+ return "[REDACTED]";
1122
+ }
1123
+ if (Array.isArray(value)) {
1124
+ return value.map((item) => redactValue(key, item));
1125
+ }
1126
+ if (value !== null && typeof value === "object" && !isPlainObject$1(value)) {
1127
+ const entries = Object.entries(value);
1128
+ if (entries.length === 0) {
1129
+ return value;
1130
+ }
1131
+ return Object.assign(
1132
+ Object.create(Object.getPrototypeOf(value)),
1133
+ Object.fromEntries(entries.map(([k, v]) => [k, redactValue(k, v)]))
1134
+ );
1135
+ }
1136
+ if (value !== null && typeof value === "object") {
1137
+ return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactValue(k, v)]));
1138
+ }
1139
+ return value;
1140
+ };
1141
+ const describeValue = (path, value) => {
1142
+ if (SENSITIVE_KEY_PATTERN.test(path)) {
1143
+ return "[REDACTED]";
1144
+ }
657
1145
  if (value === null || typeof value !== "object" && typeof value !== "function") {
658
1146
  return String(value);
659
1147
  }
660
1148
  try {
661
- return JSON.stringify(value) ?? String(value);
1149
+ return JSON.stringify(redactValue(path, value)) ?? String(value);
662
1150
  } catch {
663
1151
  return `[${typeof value}]`;
664
1152
  }
@@ -666,14 +1154,18 @@ const describeValue = (value) => {
666
1154
  const validateValue = (path, value, mapping, errors, rejectUnknownKeys) => {
667
1155
  if (isPrimitive(mapping)) {
668
1156
  if (typeof value !== mapping) {
669
- errors.push(`Invalid ${path}: ${describeValue(value)}. Expected type: ${mapping}`);
1157
+ errors.push(
1158
+ `Invalid ${path}: ${describeValue(path, value)}. Expected type: ${mapping}`
1159
+ );
670
1160
  }
671
1161
  return;
672
1162
  }
673
1163
  if (isArrayType(mapping)) {
674
1164
  const elementType = mapping.slice(0, -2);
675
1165
  if (!Array.isArray(value) || !value.every((element) => typeof element === elementType)) {
676
- errors.push(`Invalid ${path}: ${describeValue(value)}. Expected type: ${mapping}`);
1166
+ errors.push(
1167
+ `Invalid ${path}: ${describeValue(path, value)}. Expected type: ${mapping}`
1168
+ );
677
1169
  }
678
1170
  return;
679
1171
  }
@@ -682,13 +1174,13 @@ const validateValue = (path, value, mapping, errors, rejectUnknownKeys) => {
682
1174
  value.forEach((item, index) => {
683
1175
  if (!mapping.includes(item)) {
684
1176
  errors.push(
685
- `Invalid ${path}[${index}]: ${describeValue(item)}. Expected one of: ${mapping.join(", ")}`
1177
+ `Invalid ${path}[${index}]: ${describeValue(`${path}[${index}]`, item)}. Expected one of: ${mapping.join(", ")}`
686
1178
  );
687
1179
  }
688
1180
  });
689
1181
  } else if (!mapping.includes(value)) {
690
1182
  errors.push(
691
- `Invalid ${path}: ${describeValue(value)}. Expected one of: ${mapping.join(", ")}`
1183
+ `Invalid ${path}: ${describeValue(path, value)}. Expected one of: ${mapping.join(", ")}`
692
1184
  );
693
1185
  }
694
1186
  return;
@@ -705,7 +1197,7 @@ const validateValue = (path, value, mapping, errors, rejectUnknownKeys) => {
705
1197
  };
706
1198
  const validateObject = (path, value, mapping, errors, rejectUnknownKeys) => {
707
1199
  if (value === null || typeof value !== "object") {
708
- errors.push(`Invalid ${path}: ${describeValue(value)}. Expected an object.`);
1200
+ errors.push(`Invalid ${path}: ${describeValue(path, value)}. Expected an object.`);
709
1201
  return;
710
1202
  }
711
1203
  for (const [prop, propValue] of Object.entries(value)) {
@@ -774,22 +1266,18 @@ class GraphQLOperation extends BaseOperation {
774
1266
  *
775
1267
  * @param query - The GraphQL document to execute.
776
1268
  * @param variables - The variables for the document. When omitted the request body contains only the query.
777
- * @param requiresAuth - Whether the operation requires an authentication token.
778
- * @param operation - Optional human-readable operation name included in missing-token auth errors. Defaults to the concrete operation class name.
779
- * @param transportOptions - Optional per-request transport settings merged over the instance-level ones. A field set here wins; unset fields keep the instance value.
1269
+ * @param options - Named trailing options; see {@link GraphQLRequestOptions}.
780
1270
  * @returns The unwrapped response data. For documents with a single root field this is the bare field value; otherwise it is the full `{ data }` envelope.
781
1271
  * @throws An {@link AniLinkAuthError} when `requiresAuth` is true and no token is set, or a normalized {@link AniLinkError} when the request fails.
782
1272
  */
783
- async request(query, variables, requiresAuth = false, operation, transportOptions) {
1273
+ async request(query, variables, options = {}) {
1274
+ const { requiresAuth, operation, transportOptions } = options;
784
1275
  const data = variables === void 0 ? { query } : { query, variables };
785
- return await this.dispatch(
786
- this.graphqlUrl,
787
- "POST",
788
- data,
1276
+ return await this.dispatch(this.graphqlUrl, "POST", data, {
789
1277
  requiresAuth,
790
1278
  operation,
791
1279
  transportOptions
792
- );
1280
+ });
793
1281
  }
794
1282
  /**
795
1283
  * Runs the shared validate-then-dispatch pipeline for an operation.
@@ -817,7 +1305,7 @@ class GraphQLOperation extends BaseOperation {
817
1305
  if (mappings && variables !== void 0) {
818
1306
  validateVariables(variables, mappings);
819
1307
  }
820
- return await this.request(query, variables, requiresAuth, void 0, transportOptions);
1308
+ return await this.request(query, variables, { requiresAuth, transportOptions });
821
1309
  }
822
1310
  }
823
1311
 
@@ -855,7 +1343,7 @@ class CustomRequest extends AniListOperation {
855
1343
  * @param options - Optional per-request transport settings merged over the instance-level ones for this call only.
856
1344
  * @returns A promise that resolves to the unwrapped response data for single-root-field documents, or the full `{ data }` envelope otherwise.
857
1345
  * @throws An {@link AniLinkValidationError} when the query is empty or does not declare a `query` or `mutation` operation.
858
- * @throws An `AniLinkError` when the request fails. When AniList returns partial success (some fields resolve while others fail inside an HTTP 200 envelope), the thrown {@link AniLinkGraphQLError} exposes the resolved portion via its `partialData` field, so the fields that did resolve remain recoverable from the error.
1346
+ * @throws An `AniLinkError` when the request fails. When AniList returns partial success (some fields resolve while others fail inside an HTTP 200 envelope), the thrown `AniLinkGraphQLError` exposes the resolved portion via its `partialData` field, so the fields that did resolve remain recoverable from the error.
859
1347
  * @see https://docs.anilist.co/reference/query
860
1348
  * @see https://docs.anilist.co/reference/mutation
861
1349
  */
@@ -865,7 +1353,7 @@ class CustomRequest extends AniListOperation {
865
1353
  "custom() requires a GraphQL document declaring a query or mutation operation"
866
1354
  ]);
867
1355
  }
868
- return await this.request(query, variables, false, void 0, options);
1356
+ return await this.request(query, variables, { transportOptions: options });
869
1357
  }
870
1358
  }
871
1359
 
@@ -924,34 +1412,70 @@ function resolvePositiveInt(value, fallback) {
924
1412
  function resolveCappedInt(value, max, fallback) {
925
1413
  return Math.min(resolvePositiveInt(value, fallback), max);
926
1414
  }
927
- async function fetchWithLookAhead(fetch, extractHasMore, ...rest) {
928
- let extractNextKey;
929
- let firstKey;
930
- let numericStart;
931
- let maxEntries;
932
- let concurrency;
933
- if (rest.length === 4) {
934
- [extractNextKey, firstKey, maxEntries, concurrency] = rest;
935
- if (extractNextKey === void 0) {
936
- numericStart = firstKey;
937
- }
938
- } else {
939
- [numericStart, maxEntries, concurrency] = rest;
940
- firstKey = numericStart;
1415
+ function bridgeAbortSignal(external) {
1416
+ const controller = new AbortController();
1417
+ if (external === void 0) {
1418
+ return { signal: controller.signal, dispose: () => controller.abort() };
1419
+ }
1420
+ if (external.aborted) {
1421
+ controller.abort();
1422
+ return { signal: controller.signal, dispose: () => {
1423
+ } };
1424
+ }
1425
+ const onAbort = () => controller.abort();
1426
+ external.addEventListener("abort", onAbort, { once: true });
1427
+ return {
1428
+ signal: controller.signal,
1429
+ dispose: () => {
1430
+ controller.abort();
1431
+ external.removeEventListener("abort", onAbort);
1432
+ }
1433
+ };
1434
+ }
1435
+ async function fetchWithLookAhead(fetch, extractHasMore, extractNextKeyOrStartNumber, firstKeyOrMaxEntries, maxEntriesOrConcurrency, concurrencyOrSignal, maybeSignal) {
1436
+ if (extractNextKeyOrStartNumber === void 0) {
1437
+ return fetchNumericWithLookAhead(
1438
+ fetch,
1439
+ extractHasMore,
1440
+ firstKeyOrMaxEntries,
1441
+ maxEntriesOrConcurrency,
1442
+ concurrencyOrSignal,
1443
+ maybeSignal
1444
+ );
1445
+ }
1446
+ if (typeof extractNextKeyOrStartNumber === "function") {
1447
+ return fetchCursorChain(
1448
+ fetch,
1449
+ extractHasMore,
1450
+ extractNextKeyOrStartNumber,
1451
+ firstKeyOrMaxEntries,
1452
+ maxEntriesOrConcurrency);
941
1453
  }
1454
+ return fetchNumericWithLookAhead(
1455
+ fetch,
1456
+ extractHasMore,
1457
+ extractNextKeyOrStartNumber,
1458
+ firstKeyOrMaxEntries,
1459
+ maxEntriesOrConcurrency,
1460
+ typeof concurrencyOrSignal === "number" ? void 0 : concurrencyOrSignal
1461
+ );
1462
+ }
1463
+ async function fetchNumericWithLookAhead(fetch, extractHasMore, startNumber, maxEntries, concurrency, signal) {
942
1464
  const responses = [];
943
1465
  const pending = [];
944
1466
  let launched = 0;
945
1467
  let count = 0;
946
1468
  let truncated = false;
947
- let pendingCursorKey = extractNextKey === void 0 ? void 0 : firstKey;
948
- const effectiveConcurrency = extractNextKey === void 0 ? concurrency : 1;
949
1469
  while (count < maxEntries) {
950
- while (launched < maxEntries && launched - count < effectiveConcurrency) {
1470
+ if (signal?.aborted) {
1471
+ await Promise.allSettled(pending.slice(count));
1472
+ responses.length = count;
1473
+ return { responses, count, truncated: false };
1474
+ }
1475
+ while (launched < maxEntries && launched - count < concurrency) {
951
1476
  const slot = launched;
952
- const key = extractNextKey === void 0 ? numericStart + slot : pendingCursorKey;
953
1477
  launched += 1;
954
- const request = fetch(key).then((response) => {
1478
+ const request = fetch(startNumber + slot).then((response) => {
955
1479
  responses[slot] = response;
956
1480
  });
957
1481
  pending[slot] = request;
@@ -959,25 +1483,55 @@ async function fetchWithLookAhead(fetch, extractHasMore, ...rest) {
959
1483
  });
960
1484
  }
961
1485
  if (count >= launched) break;
962
- await pending[count];
1486
+ try {
1487
+ await pending[count];
1488
+ } catch (err) {
1489
+ if (signal?.aborted) {
1490
+ await Promise.allSettled(pending.slice(count + 1));
1491
+ responses.length = count;
1492
+ return { responses, count, truncated: false };
1493
+ }
1494
+ throw err;
1495
+ }
963
1496
  count += 1;
964
- const consumed = responses[count - 1];
965
- const hasMore = extractHasMore(consumed);
966
- if (!hasMore) {
1497
+ if (!extractHasMore(responses[count - 1])) {
967
1498
  await Promise.allSettled(pending.slice(count));
968
1499
  responses.length = count;
969
1500
  return { responses, count, truncated: false };
970
1501
  }
971
- if (extractNextKey !== void 0) {
972
- pendingCursorKey = extractNextKey(consumed);
973
- }
974
1502
  if (count >= maxEntries) {
975
1503
  await Promise.allSettled(pending.slice(count));
976
1504
  truncated = true;
977
1505
  break;
978
1506
  }
979
1507
  }
980
- return { responses, count, truncated };
1508
+ return { responses, count, truncated };
1509
+ }
1510
+ async function fetchCursorChain(fetch, extractHasMore, extractNextKey, firstKey, maxEntries, signal) {
1511
+ const responses = [];
1512
+ let key = firstKey;
1513
+ while (responses.length < maxEntries && key !== void 0) {
1514
+ let entry;
1515
+ try {
1516
+ entry = await fetch(key);
1517
+ } catch (err) {
1518
+ throw err;
1519
+ }
1520
+ responses.push(entry);
1521
+ if (!extractHasMore(entry)) {
1522
+ return { responses, count: responses.length, truncated: false };
1523
+ }
1524
+ key = extractNextKey(entry);
1525
+ }
1526
+ return {
1527
+ responses,
1528
+ count: responses.length,
1529
+ // A degenerate guard (maxEntries <= 0) fetched nothing and cut
1530
+ // nothing short: `truncated` reports whether the guard ended a run
1531
+ // that still had data, matching the numeric driver's `while (count <
1532
+ // maxEntries)` early exit.
1533
+ truncated: responses.length >= maxEntries && maxEntries > 0
1534
+ };
981
1535
  }
982
1536
 
983
1537
  const DEFAULT_PER_PAGE = 50;
@@ -986,6 +1540,10 @@ const DEFAULT_MAX_PAGES = 100;
986
1540
  const DEFAULT_PER_CHUNK = 500;
987
1541
  const MAX_PER_CHUNK = DEFAULT_PER_CHUNK;
988
1542
  const DEFAULT_MAX_CHUNKS = 100;
1543
+ const safeCallback = (callback, name, payload) => {
1544
+ safeInvoke(callback, name, void 0, payload);
1545
+ };
1546
+ const DEFAULT_CONCURRENCY = 3;
989
1547
  const MAX_CONCURRENCY = 8;
990
1548
  function extractHasMore(response) {
991
1549
  if (typeof response !== "object" || response === null) return false;
@@ -1002,59 +1560,130 @@ async function paginate(fetchPage, itemsKey, options) {
1002
1560
  const perPage = resolveCappedInt(options?.perPage, MAX_PER_PAGE, DEFAULT_PER_PAGE);
1003
1561
  const startPage = resolvePositiveInt(options?.startPage, 1);
1004
1562
  const maxPages = resolvePositiveInt(options?.maxPages, DEFAULT_MAX_PAGES);
1005
- const concurrency = resolveCappedInt(options?.concurrency, MAX_CONCURRENCY, 1);
1006
- const { responses, count, truncated } = await fetchWithLookAhead(
1007
- (number) => fetchPage(number, perPage),
1008
- extractHasMore,
1009
- startPage,
1010
- maxPages,
1011
- concurrency
1563
+ const concurrency = resolveCappedInt(
1564
+ options?.concurrency,
1565
+ MAX_CONCURRENCY,
1566
+ DEFAULT_CONCURRENCY
1012
1567
  );
1013
- const items = [];
1014
- const pages = [];
1015
- for (const response of responses) {
1016
- const pageItems = response[itemsKey];
1017
- pages.push({ pageInfo: response.pageInfo, items: pageItems });
1018
- items.push(...pageItems);
1019
- }
1020
- return { items, pages, pageCount: count, truncated };
1568
+ const { signal, dispose } = bridgeAbortSignal(options?.signal);
1569
+ try {
1570
+ const { responses, count, truncated } = await fetchWithLookAhead(
1571
+ (number) => fetchPage(number, perPage, signal),
1572
+ extractHasMore,
1573
+ startPage,
1574
+ maxPages,
1575
+ concurrency,
1576
+ signal
1577
+ );
1578
+ const items = [];
1579
+ const pages = [];
1580
+ for (const response of responses) {
1581
+ const pageItems = response[itemsKey];
1582
+ pages.push({ pageInfo: response.pageInfo, items: pageItems });
1583
+ items.push(...pageItems);
1584
+ safeCallback(options?.onPage, "onPage", {
1585
+ pageInfo: response.pageInfo,
1586
+ items: pageItems
1587
+ });
1588
+ }
1589
+ return { items, pages, pageCount: count, truncated };
1590
+ } finally {
1591
+ dispose();
1592
+ }
1021
1593
  }
1022
1594
  async function* paginatePages(fetchPage, options) {
1023
1595
  const perPage = resolveCappedInt(options?.perPage, MAX_PER_PAGE, DEFAULT_PER_PAGE);
1024
1596
  const startPage = resolvePositiveInt(options?.startPage, 1);
1025
1597
  const maxPages = resolvePositiveInt(options?.maxPages, DEFAULT_MAX_PAGES);
1026
- let page = startPage;
1027
- let pageCount = 0;
1028
- while (pageCount < maxPages) {
1029
- const response = await fetchPage(page, perPage);
1030
- pageCount += 1;
1031
- yield response;
1032
- if (!response.pageInfo.hasNextPage || pageCount >= maxPages) {
1033
- break;
1598
+ const concurrency = resolveCappedInt(
1599
+ options?.concurrency,
1600
+ MAX_CONCURRENCY,
1601
+ DEFAULT_CONCURRENCY
1602
+ );
1603
+ const { signal, dispose } = bridgeAbortSignal(options?.signal);
1604
+ if (signal.aborted) {
1605
+ dispose();
1606
+ return;
1607
+ }
1608
+ const pending = /* @__PURE__ */ new Map();
1609
+ let nextToLaunch = startPage;
1610
+ let nextToYield = startPage;
1611
+ let terminal = false;
1612
+ const launchWindow = () => {
1613
+ while (!terminal && nextToLaunch - startPage < maxPages && pending.size < concurrency) {
1614
+ const page = nextToLaunch;
1615
+ nextToLaunch += 1;
1616
+ const request = fetchPage(page, perPage, signal);
1617
+ pending.set(page, request);
1618
+ void request.catch(() => {
1619
+ });
1034
1620
  }
1035
- page += 1;
1621
+ };
1622
+ try {
1623
+ while (nextToYield - startPage < maxPages) {
1624
+ launchWindow();
1625
+ const page = nextToYield;
1626
+ const request = pending.get(page);
1627
+ if (request === void 0) {
1628
+ break;
1629
+ }
1630
+ let response;
1631
+ try {
1632
+ response = await request;
1633
+ } catch (err) {
1634
+ if (signal.aborted) {
1635
+ break;
1636
+ }
1637
+ throw err;
1638
+ }
1639
+ pending.delete(page);
1640
+ nextToYield += 1;
1641
+ yield response;
1642
+ if (!response.pageInfo.hasNextPage) {
1643
+ terminal = true;
1644
+ await Promise.allSettled([...pending.values()]);
1645
+ break;
1646
+ }
1647
+ }
1648
+ } finally {
1649
+ dispose();
1650
+ pending.clear();
1036
1651
  }
1037
1652
  }
1038
1653
  async function paginateChunks(fetchChunk, itemsKey, options) {
1039
1654
  const perChunk = resolveCappedInt(options?.perChunk, MAX_PER_CHUNK, DEFAULT_PER_CHUNK);
1040
1655
  const startChunk = resolvePositiveInt(options?.startChunk, 1);
1041
1656
  const maxChunks = resolvePositiveInt(options?.maxChunks, DEFAULT_MAX_CHUNKS);
1042
- const concurrency = resolveCappedInt(options?.concurrency, MAX_CONCURRENCY, 1);
1043
- const { responses, count, truncated } = await fetchWithLookAhead(
1044
- (number) => fetchChunk(number, perChunk),
1045
- extractHasMore,
1046
- startChunk,
1047
- maxChunks,
1048
- concurrency
1657
+ const concurrency = resolveCappedInt(
1658
+ options?.concurrency,
1659
+ MAX_CONCURRENCY,
1660
+ DEFAULT_CONCURRENCY
1049
1661
  );
1050
- const items = [];
1051
- const chunks = [];
1052
- for (const response of responses) {
1053
- const chunkItems = response[itemsKey];
1054
- chunks.push({ hasNextChunk: response.hasNextChunk, items: chunkItems });
1055
- items.push(...chunkItems);
1056
- }
1057
- return { items, chunks, chunkCount: count, truncated };
1662
+ const { signal, dispose } = bridgeAbortSignal(options?.signal);
1663
+ try {
1664
+ const { responses, count, truncated } = await fetchWithLookAhead(
1665
+ (number) => fetchChunk(number, perChunk, signal),
1666
+ extractHasMore,
1667
+ startChunk,
1668
+ maxChunks,
1669
+ concurrency,
1670
+ signal
1671
+ );
1672
+ const items = [];
1673
+ const chunks = [];
1674
+ for (const response of responses) {
1675
+ const chunkItems = response[itemsKey];
1676
+ chunks.push({ hasNextChunk: response.hasNextChunk, items: chunkItems });
1677
+ items.push(...chunkItems);
1678
+ safeCallback(options?.onChunk, "onChunk", {
1679
+ hasNextChunk: response.hasNextChunk,
1680
+ items: chunkItems
1681
+ });
1682
+ }
1683
+ return { items, chunks, chunkCount: count, truncated };
1684
+ } finally {
1685
+ dispose();
1686
+ }
1058
1687
  }
1059
1688
 
1060
1689
  const MediaSortMappings = [
@@ -3137,7 +3766,7 @@ class GenreCollectionQuery extends AniListOperation {
3137
3766
  /**
3138
3767
  * {@link GenreCollectionQuery.genreCollection} sends a query request to get genre collections.
3139
3768
  *
3140
- * @returns The genre strings returned by AniList.
3769
+ * @returns The list of genre strings returned by AniList.
3141
3770
  * @see https://docs.anilist.co/reference/query
3142
3771
  * @param options - Optional {@link RequestOptions} merged over the instance-level settings for this call only.
3143
3772
  * @example
@@ -5707,7 +6336,7 @@ class ToggleLikeMutation extends AniListOperation {
5707
6336
  /**
5708
6337
  * {@link ToggleLikeMutation.toggleLike} sends a mutation request to toggle a like.
5709
6338
  *
5710
- * @deprecated Prefer {@link ToggleLikeV2Mutation.toggleLikeV2}, which returns the richer {@link Likeable} union (activity, activity reply, thread, or thread comment) instead of a bare user.
6339
+ * @deprecated Prefer `ToggleLikeV2Mutation.toggleLikeV2`, which returns the richer `Likeable` union (activity, activity reply, thread, or thread comment) instead of a bare user.
5711
6340
  * @param variables - Values from {@link ToggleLikeVariables} for the mutation.
5712
6341
  * @returns The {@link BasicUser} returned by the mutation.
5713
6342
  * @throws Throws if no authentication token is configured, `id` or `type` is missing or invalid, or the mutation request fails.
@@ -6766,7 +7395,7 @@ class SaveMediaListEntryMutation extends AniListOperation {
6766
7395
  }
6767
7396
 
6768
7397
  function op(name, operationClass) {
6769
- return { name, operationClass };
7398
+ return { name, operationClass, methodName: name };
6770
7399
  }
6771
7400
  function opAs(name, operationClass, methodName) {
6772
7401
  return { name, operationClass, methodName };
@@ -6852,52 +7481,125 @@ const ANILIST_OPERATION_REGISTRY = {
6852
7481
  ]
6853
7482
  };
6854
7483
 
6855
- function instantiateCategory(category, authToken, options) {
6856
- return ANILIST_OPERATION_REGISTRY[category].map((entry) => ({
6857
- name: entry.name,
6858
- methodName: entry.methodName,
6859
- instance: new entry.operationClass(authToken, options)
6860
- }));
6861
- }
6862
- function bindEntries(entries) {
6863
- const bound = {};
6864
- for (const { name, instance, methodName } of entries) {
6865
- const method = instance[methodName ?? name];
7484
+ function validateCategoryMethods(category) {
7485
+ for (const entry of ANILIST_OPERATION_REGISTRY[category]) {
7486
+ const method = entry.operationClass.prototype[entry.methodName];
6866
7487
  if (typeof method !== "function") {
6867
7488
  throw new TypeError(
6868
- `Operation "${name}" does not expose a "${methodName ?? name}" method to bind.`
7489
+ `Operation "${entry.name}" does not expose a "${entry.methodName}" method to bind.`
6869
7490
  );
6870
7491
  }
6871
- bound[name] = method.bind(instance);
6872
7492
  }
6873
- return bound;
7493
+ }
7494
+ function buildLazyGroup(category, authToken, options) {
7495
+ const entries = ANILIST_OPERATION_REGISTRY[category];
7496
+ const descriptors = {};
7497
+ for (const entry of entries) {
7498
+ let bound;
7499
+ descriptors[entry.name] = {
7500
+ enumerable: true,
7501
+ configurable: false,
7502
+ get() {
7503
+ if (bound === void 0) {
7504
+ const instance = new entry.operationClass(
7505
+ authToken,
7506
+ options
7507
+ );
7508
+ const method = instance[entry.methodName];
7509
+ if (typeof method !== "function") {
7510
+ throw new TypeError(
7511
+ `Operation "${entry.name}" does not expose a "${entry.methodName}" method to bind.`
7512
+ );
7513
+ }
7514
+ bound = method.bind(instance);
7515
+ }
7516
+ return bound;
7517
+ }
7518
+ };
7519
+ }
7520
+ return Object.defineProperties({}, descriptors);
6874
7521
  }
6875
7522
  function buildAniListWiring(authToken, options) {
6876
- const customInstance = new CustomRequest(authToken, options);
6877
- const [queryEntries, pageEntries, mutationEntries] = ["query", "page", "mutation"].map((category) => instantiateCategory(category, authToken, options));
6878
- return {
6879
- custom: customInstance.custom.bind(customInstance),
6880
- query: {
6881
- ...bindEntries(queryEntries),
6882
- page: bindEntries(pageEntries)
7523
+ for (const category of ["query", "page", "mutation"]) {
7524
+ validateCategoryMethods(category);
7525
+ }
7526
+ const queryFacade = buildLazyGroup("query", authToken, options);
7527
+ const pageFacade = buildLazyGroup("page", authToken, options);
7528
+ const mutationFacade = buildLazyGroup("mutation", authToken, options);
7529
+ Object.defineProperty(queryFacade, "page", {
7530
+ value: pageFacade,
7531
+ enumerable: true,
7532
+ configurable: false,
7533
+ writable: false
7534
+ });
7535
+ let customBound;
7536
+ return Object.defineProperties(
7537
+ {
7538
+ query: queryFacade,
7539
+ mutation: mutationFacade,
7540
+ paginate,
7541
+ paginatePages,
7542
+ paginateChunks,
7543
+ fuzzyDate,
7544
+ flattenMediaListCollection
6883
7545
  },
6884
- mutation: bindEntries(mutationEntries),
6885
- paginate,
6886
- paginatePages,
6887
- paginateChunks,
6888
- fuzzyDate,
6889
- flattenMediaListCollection
6890
- };
7546
+ {
7547
+ custom: {
7548
+ enumerable: true,
7549
+ configurable: false,
7550
+ get() {
7551
+ if (customBound === void 0) {
7552
+ const customInstance = new CustomRequest(authToken, options);
7553
+ customBound = customInstance.custom.bind(customInstance);
7554
+ }
7555
+ return customBound;
7556
+ }
7557
+ }
7558
+ }
7559
+ );
6891
7560
  }
6892
7561
 
6893
7562
  function buildAniListApi(authToken, options) {
6894
7563
  return buildAniListWiring(authToken, options);
6895
7564
  }
6896
7565
 
7566
+ const TRANSPORT_OPTION_KEYS = [
7567
+ "timeout",
7568
+ "signal",
7569
+ "exposeRawAxiosError",
7570
+ "retry",
7571
+ "paceWithRateLimit",
7572
+ "rateLimitFloor",
7573
+ "circuitBreaker",
7574
+ "retryBudget",
7575
+ "maxSockets",
7576
+ "maxFreeSockets",
7577
+ "onError",
7578
+ "onRetry",
7579
+ "onRequestStart",
7580
+ "onResponse",
7581
+ "onPace",
7582
+ "onHookError",
7583
+ "onCircuitOpen",
7584
+ "onCircuitClose",
7585
+ "ignorePaceDeadline",
7586
+ "responseCache"
7587
+ ];
7588
+
6897
7589
  const resolveTransportOptions = (credentials, providerFields) => {
6898
- const options = Object.fromEntries(
6899
- Object.entries(credentials).filter(([key]) => !providerFields.includes(key))
6900
- );
7590
+ const allowedKeys = /* @__PURE__ */ new Set([...TRANSPORT_OPTION_KEYS, ...providerFields]);
7591
+ const options = {};
7592
+ for (const [key, value] of Object.entries(credentials)) {
7593
+ if (providerFields.includes(key)) continue;
7594
+ if (!allowedKeys.has(key)) {
7595
+ throw new TypeError(
7596
+ `Unknown credential key "${key}". Valid transport options are: ${TRANSPORT_OPTION_KEYS.join(
7597
+ ", "
7598
+ )}. Provider auth fields are: ${providerFields.join(", ")}.`
7599
+ );
7600
+ }
7601
+ options[key] = value;
7602
+ }
6901
7603
  return Object.keys(options).length === 0 ? void 0 : options;
6902
7604
  };
6903
7605
  function resolveAniListCredentials(credentials) {
@@ -6944,37 +7646,40 @@ class RestOperation extends BaseOperation {
6944
7646
  /**
6945
7647
  * Sends one REST call through the shared transport pipeline.
6946
7648
  *
6947
- * GET and DELETE calls pass their parameters as a query string; POST and
6948
- * PUT calls send them as a JSON body. Responses are returned verbatim
6949
- * REST providers have no GraphQL-style envelope, so no unwrapping happens.
7649
+ * GET and DELETE calls pass their parameters as a query string; POST, PUT,
7650
+ * and PATCH calls send them as a JSON body. Responses are returned verbatim
7651
+ * REST providers have no GraphQL-style envelope, so no unwrapping
7652
+ * happens.
6950
7653
  *
6951
7654
  * @typeParam T - The expected parsed response body.
6952
7655
  * @param path - The endpoint path beginning with `/` (for example `/anime/{id}`); placeholders are substituted from `pathParams` before interpolation into the URL.
6953
- * @param options - The declarative request contract: method, auth requirement, content type, and per-request transport settings.
6954
- * @param query - Query parameters appended to the URL (GET/DELETE), when provided.
6955
- * @param body - The JSON request body (POST/PUT), when provided.
6956
- * @param pathParams - Values substituted into `{placeholder}` segments of `path`. Defaults to an empty map so paths without placeholders need none.
7656
+ * @param options - The declarative request contract: method, auth requirement, content type, query/body/pathParams, and per-request transport settings.
6957
7657
  * @returns The parsed response body as-is.
6958
7658
  * @throws An {@link AniLinkAuthError} when `requiresAuth` is true and no token is set, or a normalized {@link AniLinkError} (typically `AniLinkRestError`) when the request fails.
6959
7659
  */
6960
- async execute(path, options = {}, query, body, pathParams = {}) {
6961
- const { method = "GET", requiresAuth = false, contentType, transportOptions } = options;
7660
+ async execute(path, options = {}) {
7661
+ const {
7662
+ method = "GET",
7663
+ requiresAuth = false,
7664
+ contentType,
7665
+ query,
7666
+ body,
7667
+ pathParams = {},
7668
+ transportOptions
7669
+ } = options;
6962
7670
  const interpolatedPath = path.replace(/\{(\w+)\}/g, (match, name) => {
6963
7671
  const value = pathParams[name];
6964
7672
  return value === void 0 ? match : encodeURIComponent(String(value));
6965
7673
  });
6966
7674
  const url = `${this.baseUrl}${interpolatedPath}${buildQueryString(query ?? {})}`;
6967
- const carriesBody = method === "POST" || method === "PUT";
7675
+ const carriesBody = method === "POST" || method === "PUT" || method === "PATCH";
6968
7676
  const effectiveContentType = contentType ?? "application/json";
6969
- return await this.dispatch(
6970
- url,
6971
- method,
6972
- carriesBody ? body : void 0,
7677
+ return await this.dispatch(url, method, carriesBody ? body : void 0, {
6973
7678
  requiresAuth,
6974
- void 0,
6975
7679
  transportOptions,
6976
- effectiveContentType
6977
- );
7680
+ contentType: effectiveContentType,
7681
+ protocol: "rest"
7682
+ });
6978
7683
  }
6979
7684
  }
6980
7685
 
@@ -7004,13 +7709,204 @@ class MalAnimeOperation extends RestOperation {
7004
7709
  */
7005
7710
  async get(id, options = {}) {
7006
7711
  const { fields, ...transportOptions } = options;
7007
- return await this.execute(
7008
- "/anime/{id}",
7009
- { transportOptions },
7010
- fields === void 0 ? void 0 : { fields: Array.isArray(fields) ? fields.join(",") : fields },
7011
- void 0,
7012
- { id }
7013
- );
7712
+ return await this.execute("/anime/{id}", {
7713
+ transportOptions,
7714
+ query: fields === void 0 ? void 0 : { fields: Array.isArray(fields) ? fields.join(",") : fields },
7715
+ pathParams: { id }
7716
+ });
7717
+ }
7718
+ /**
7719
+ * Encodes a {@link MalAnimeListStatusUpdate} as the form-urlencoded body
7720
+ * MAL's list-status endpoints require.
7721
+ *
7722
+ * MAL documents `PATCH /anime/{id}/my_list_status` with an
7723
+ * `application/x-www-form-urlencoded` request body, not JSON. Array values
7724
+ * (`tags`) are joined into the comma-separated string MAL expects.
7725
+ *
7726
+ * @param payload - The list-status fields to update.
7727
+ * @returns The encoded body string, safe to pass as the request `data`.
7728
+ */
7729
+ encodeListStatusBody(payload) {
7730
+ const params = new URLSearchParams();
7731
+ for (const [key, value] of Object.entries(payload)) {
7732
+ if (value === void 0) continue;
7733
+ if (Array.isArray(value)) {
7734
+ if (Array.isArray(value)) {
7735
+ params.set(key, value.join(","));
7736
+ }
7737
+ } else {
7738
+ params.set(key, String(value));
7739
+ }
7740
+ }
7741
+ return params.toString();
7742
+ }
7743
+ /**
7744
+ * {@link MalAnimeOperation.updateMyListStatus} updates the authenticated user's anime list status.
7745
+ *
7746
+ * It calls `PATCH /anime/{id}/my_list_status` through `RestOperation.execute` with `requiresAuth` and a form-urlencoded {@link MalAnimeListStatusUpdate} body (MAL rejects JSON on this endpoint), returning the updated {@link MalAnimeListStatus}. The facade alias is `MyAnimeListAnimeApi.updateMyListStatus` and it requires `MalCredentials.accessToken`.
7747
+ *
7748
+ * @param id - The MyAnimeList anime ID.
7749
+ * @param payload - The list-status fields to update; a {@link MalAnimeListStatusUpdate} of only the fields to change.
7750
+ * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
7751
+ * @returns The updated {@link MalAnimeListStatus}.
7752
+ * @throws An `AniLinkAuthError` without an access token, or a normalized request error.
7753
+ * @example
7754
+ * ```typescript
7755
+ * const api = new AniLink({ mal: { accessToken: "mal-token" } }).mal;
7756
+ * const status = await api.anime.updateMyListStatus(21, {
7757
+ * status: "watching",
7758
+ * num_watched_episodes: 10,
7759
+ * score: 9,
7760
+ * });
7761
+ * ```
7762
+ * @see https://myanimelist.net/apiconfig/references/api/v2#tag/user-animelist/operation/anime_anime_id_my_list_status_put
7763
+ */
7764
+ async updateMyListStatus(id, payload, options = {}) {
7765
+ const { fields, ...transportOptions } = options;
7766
+ return await this.execute("/anime/{id}/my_list_status", {
7767
+ method: "PATCH",
7768
+ requiresAuth: true,
7769
+ transportOptions,
7770
+ contentType: "application/x-www-form-urlencoded",
7771
+ body: this.encodeListStatusBody(payload),
7772
+ query: fields === void 0 ? void 0 : { fields: Array.isArray(fields) ? fields.join(",") : fields },
7773
+ pathParams: { id }
7774
+ });
7775
+ }
7776
+ /**
7777
+ * {@link MalAnimeOperation.deleteFromList} removes an anime from the authenticated user's list.
7778
+ *
7779
+ * It calls `DELETE /anime/{id}/my_list_status` through `RestOperation.execute` with `requiresAuth` and resolves with no body. The facade alias is `MyAnimeListAnimeApi.deleteFromList` and it requires `MalCredentials.accessToken`.
7780
+ *
7781
+ * @param id - The MyAnimeList anime ID.
7782
+ * @param options - Optional transport settings; a {@link MalRequestOptions} merged over the instance defaults.
7783
+ * @returns Resolves once the entry is deleted; the response carries no body.
7784
+ * @throws An `AniLinkAuthError` without an access token, or a normalized request error.
7785
+ * @example
7786
+ * ```typescript
7787
+ * const api = new AniLink({ mal: { accessToken: "mal-token" } }).mal;
7788
+ * await api.anime.deleteFromList(21);
7789
+ * ```
7790
+ * @see https://myanimelist.net/apiconfig/references/api/v2#tag/user-animelist/operation/anime_anime_id_my_list_status_delete
7791
+ */
7792
+ async deleteFromList(id, options = {}) {
7793
+ const { fields: _ignoredFields, ...transportOptions } = options;
7794
+ await this.execute("/anime/{id}/my_list_status", {
7795
+ method: "DELETE",
7796
+ requiresAuth: true,
7797
+ transportOptions,
7798
+ pathParams: { id }
7799
+ });
7800
+ }
7801
+ }
7802
+
7803
+ class MalMangaOperation extends RestOperation {
7804
+ /** The base URL for MyAnimeList API v2, from {@link MAL_API_BASE_URL}. */
7805
+ baseUrl = MAL_API_BASE_URL;
7806
+ /**
7807
+ * {@link MalMangaOperation.get} gets one manga by its MyAnimeList ID.
7808
+ *
7809
+ * It calls `GET /manga/{id}` through `RestOperation.execute` and returns a {@link MalManga} shaped by {@link MalRequestOptions.fields}. The facade alias is `MyAnimeListMangaApi.get`.
7810
+ *
7811
+ * @param id - The MyAnimeList manga ID.
7812
+ * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
7813
+ * @returns The requested {@link MalManga}.
7814
+ * @throws A normalized `AniLinkError` when the request fails.
7815
+ * @example
7816
+ * ```typescript
7817
+ * const api = new AniLink({ mal: { accessToken: "mal-token" } }).mal;
7818
+ * const manga = await api.manga.get(1, { fields: ["id", "title"] });
7819
+ * ```
7820
+ * @see https://myanimelist.net/apiconfig/references/api/v2#tag/manga/operation/manga_manga_id_get
7821
+ */
7822
+ async get(id, options = {}) {
7823
+ const { fields, ...transportOptions } = options;
7824
+ return await this.execute("/manga/{id}", {
7825
+ transportOptions,
7826
+ query: fields === void 0 ? void 0 : { fields: Array.isArray(fields) ? fields.join(",") : fields },
7827
+ pathParams: { id }
7828
+ });
7829
+ }
7830
+ /**
7831
+ * Encodes a {@link MalMangaListStatusUpdate} as the form-urlencoded body
7832
+ * MAL's list-status endpoints require.
7833
+ *
7834
+ * MAL documents `PATCH /manga/{id}/my_list_status` with an
7835
+ * `application/x-www-form-urlencoded` request body, not JSON. Array values
7836
+ * (`tags`) are joined into the comma-separated string MAL expects.
7837
+ *
7838
+ * @param payload - The list-status fields to update.
7839
+ * @returns The encoded body string, safe to pass as the request `data`.
7840
+ */
7841
+ encodeListStatusBody(payload) {
7842
+ const params = new URLSearchParams();
7843
+ for (const [key, value] of Object.entries(payload)) {
7844
+ if (value === void 0) continue;
7845
+ if (Array.isArray(value)) {
7846
+ params.set(key, value.join(","));
7847
+ } else {
7848
+ params.set(key, String(value));
7849
+ }
7850
+ }
7851
+ return params.toString();
7852
+ }
7853
+ /**
7854
+ * {@link MalMangaOperation.updateMyListStatus} updates the authenticated user's manga list status.
7855
+ *
7856
+ * It calls `PATCH /manga/{id}/my_list_status` through `RestOperation.execute` with `requiresAuth` and a form-urlencoded {@link MalMangaListStatusUpdate} body (MAL rejects JSON on this endpoint), returning the updated {@link MalMangaListStatus}. The facade alias is `MyAnimeListMangaApi.updateMyListStatus` and it requires `MalCredentials.accessToken`.
7857
+ *
7858
+ * @param id - The MyAnimeList manga ID.
7859
+ * @param payload - The list-status fields to update; a {@link MalMangaListStatusUpdate} of only the fields to change.
7860
+ * @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
7861
+ * @returns The updated {@link MalMangaListStatus}.
7862
+ * @throws An `AniLinkAuthError` without an access token, or a normalized request error.
7863
+ * @example
7864
+ * ```typescript
7865
+ * const api = new AniLink({ mal: { accessToken: "mal-token" } }).mal;
7866
+ * const status = await api.manga.updateMyListStatus(1, {
7867
+ * status: "reading",
7868
+ * num_chapters_read: 10,
7869
+ * score: 9,
7870
+ * });
7871
+ * ```
7872
+ * @see https://myanimelist.net/apiconfig/references/api/v2#tag/user-mangalist/operation/manga_manga_id_my_list_status_put
7873
+ */
7874
+ async updateMyListStatus(id, payload, options = {}) {
7875
+ const { fields, ...transportOptions } = options;
7876
+ return await this.execute("/manga/{id}/my_list_status", {
7877
+ method: "PATCH",
7878
+ requiresAuth: true,
7879
+ transportOptions,
7880
+ contentType: "application/x-www-form-urlencoded",
7881
+ body: this.encodeListStatusBody(payload),
7882
+ query: fields === void 0 ? void 0 : { fields: Array.isArray(fields) ? fields.join(",") : fields },
7883
+ pathParams: { id }
7884
+ });
7885
+ }
7886
+ /**
7887
+ * {@link MalMangaOperation.deleteFromList} removes a manga from the authenticated user's list.
7888
+ *
7889
+ * It calls `DELETE /manga/{id}/my_list_status` through `RestOperation.execute` with `requiresAuth` and resolves with no body. The facade alias is `MyAnimeListMangaApi.deleteFromList` and it requires `MalCredentials.accessToken`.
7890
+ *
7891
+ * @param id - The MyAnimeList manga ID.
7892
+ * @param options - Optional transport settings; a {@link MalRequestOptions} merged over the instance defaults.
7893
+ * @returns Resolves once the entry is deleted; the response carries no body.
7894
+ * @throws An `AniLinkAuthError` without an access token, or a normalized request error.
7895
+ * @example
7896
+ * ```typescript
7897
+ * const api = new AniLink({ mal: { accessToken: "mal-token" } }).mal;
7898
+ * await api.manga.deleteFromList(1);
7899
+ * ```
7900
+ * @see https://myanimelist.net/apiconfig/references/api/v2#tag/user-mangalist/operation/manga_manga_id_my_list_status_delete
7901
+ */
7902
+ async deleteFromList(id, options = {}) {
7903
+ const { fields: _ignoredFields, ...transportOptions } = options;
7904
+ await this.execute("/manga/{id}/my_list_status", {
7905
+ method: "DELETE",
7906
+ requiresAuth: true,
7907
+ transportOptions,
7908
+ pathParams: { id }
7909
+ });
7014
7910
  }
7015
7911
  }
7016
7912
 
@@ -7034,20 +7930,30 @@ class MalUserOperation extends RestOperation {
7034
7930
  */
7035
7931
  async me(options = {}) {
7036
7932
  const { fields, ...transportOptions } = options;
7037
- return await this.execute(
7038
- "/users/@me",
7039
- { requiresAuth: true, transportOptions },
7040
- fields === void 0 ? void 0 : { fields: Array.isArray(fields) ? fields.join(",") : fields }
7041
- );
7933
+ return await this.execute("/users/@me", {
7934
+ requiresAuth: true,
7935
+ transportOptions,
7936
+ query: fields === void 0 ? void 0 : { fields: Array.isArray(fields) ? fields.join(",") : fields }
7937
+ });
7042
7938
  }
7043
7939
  }
7044
7940
 
7045
7941
  function buildMyAnimeListApi(credentials) {
7046
7942
  const { auth, options } = resolveMalCredentials(credentials);
7047
7943
  const anime = new MalAnimeOperation(auth, options);
7944
+ const manga = new MalMangaOperation(auth, options);
7048
7945
  const user = new MalUserOperation(auth, options);
7049
7946
  return {
7050
- anime: { get: anime.get.bind(anime) },
7947
+ anime: {
7948
+ get: anime.get.bind(anime),
7949
+ updateMyListStatus: anime.updateMyListStatus.bind(anime),
7950
+ deleteFromList: anime.deleteFromList.bind(anime)
7951
+ },
7952
+ manga: {
7953
+ get: manga.get.bind(manga),
7954
+ updateMyListStatus: manga.updateMyListStatus.bind(manga),
7955
+ deleteFromList: manga.deleteFromList.bind(manga)
7956
+ },
7051
7957
  user: { me: user.me.bind(user) }
7052
7958
  };
7053
7959
  }
@@ -7062,76 +7968,84 @@ const PROVIDER_FACTORIES = {
7062
7968
  mal: buildMalClient
7063
7969
  };
7064
7970
  function buildProviderClients(credentials = {}, legacyOptions) {
7971
+ const clientHookError = credentials.onHookError;
7972
+ const anilistSlot = clientHookError !== void 0 && credentials.anilist?.onHookError === void 0 ? { ...credentials.anilist, onHookError: clientHookError } : credentials.anilist;
7973
+ const malSlot = clientHookError !== void 0 && credentials.mal?.onHookError === void 0 ? { ...credentials.mal, onHookError: clientHookError } : credentials.mal;
7065
7974
  return {
7066
- anilist: PROVIDER_FACTORIES.anilist(credentials.anilist, legacyOptions),
7067
- mal: PROVIDER_FACTORIES.mal(credentials.mal)
7975
+ anilist: PROVIDER_FACTORIES.anilist(anilistSlot, legacyOptions),
7976
+ mal: PROVIDER_FACTORIES.mal(malSlot)
7068
7977
  };
7069
7978
  }
7070
7979
 
7071
- const AUTH_TOKEN_TIMEOUT_MS = 1e4;
7072
- const ANILIST_TOKEN_URL = "https://anilist.co/api/v2/oauth/token";
7073
- const ANILIST_AUTHORIZE_URL = "https://anilist.co/api/v2/oauth/authorize";
7074
- const buildAuthorizationUrl = (clientId, redirectUri, state) => {
7075
- let url = `${ANILIST_AUTHORIZE_URL}?client_id=${encodeURIComponent(
7076
- clientId
7077
- )}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`;
7078
- if (state !== void 0) {
7079
- url += `&state=${encodeURIComponent(state)}`;
7980
+ const sanitizeTokenError = (error, label) => {
7981
+ if (error instanceof AniLinkRestError) {
7982
+ const relabeled = new AniLinkRestError(error.status, error.data, void 0, {
7983
+ rateLimit: error.rateLimit,
7984
+ contentType: error.contentType,
7985
+ requestId: error.requestId
7986
+ });
7987
+ relabeled.message = `${label} failed with status ${error.status}.`;
7988
+ return relabeled;
7080
7989
  }
7081
- return url;
7082
- };
7083
- const normalizeTokenRequestError = (error) => {
7084
7990
  if (error instanceof AniLinkApiError) {
7085
- error.message = `Token request failed with status ${error.status}.`;
7086
- return error;
7991
+ const relabeled = new AniLinkApiError(error.status, error.data, void 0, {
7992
+ rateLimit: error.rateLimit,
7993
+ contentType: error.contentType,
7994
+ requestId: error.requestId
7995
+ });
7996
+ relabeled.message = `${label} failed with status ${error.status}.`;
7997
+ return relabeled;
7087
7998
  }
7088
7999
  if (error instanceof AniLinkError) {
7089
8000
  return error;
7090
8001
  }
7091
8002
  if (axios.isCancel(error)) {
7092
- return new AniLinkNetworkError(
7093
- AniLinkErrorCodes.ABORTED,
7094
- "The token request was cancelled."
7095
- );
8003
+ return new AniLinkNetworkError(AniLinkErrorCodes.ABORTED, `${label} was cancelled.`);
7096
8004
  }
7097
8005
  if (axios.isAxiosError(error)) {
7098
8006
  if (error.response?.status !== void 0) {
7099
8007
  const status = error.response.status;
7100
8008
  const apiError = new AniLinkApiError(status, error.response.data);
7101
- apiError.message = `Token request failed with status ${status}.`;
8009
+ apiError.message = `${label} failed with status ${status}.`;
7102
8010
  return apiError;
7103
8011
  }
7104
8012
  if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
7105
- return new AniLinkNetworkError(
7106
- AniLinkErrorCodes.TIMEOUT,
7107
- "The token request timed out."
7108
- );
8013
+ return new AniLinkNetworkError(AniLinkErrorCodes.TIMEOUT, `${label} timed out.`);
7109
8014
  }
7110
8015
  return new AniLinkNetworkError(
7111
8016
  AniLinkErrorCodes.NETWORK,
7112
- "The token request failed due to a network error."
8017
+ `${label} failed due to a network error.`
7113
8018
  );
7114
8019
  }
7115
- return new AniLinkError("The token request failed.", AniLinkErrorCodes.UNKNOWN);
8020
+ return new AniLinkError(`${label} failed.`, AniLinkErrorCodes.UNKNOWN);
8021
+ };
8022
+
8023
+ const AUTH_TOKEN_TIMEOUT_MS = 1e4;
8024
+ const ANILIST_TOKEN_URL = "https://anilist.co/api/v2/oauth/token";
8025
+ const ANILIST_AUTHORIZE_URL = "https://anilist.co/api/v2/oauth/authorize";
8026
+ const buildAuthorizationUrl = (clientId, redirectUri, state) => {
8027
+ let url = `${ANILIST_AUTHORIZE_URL}?client_id=${encodeURIComponent(
8028
+ clientId
8029
+ )}&redirect_uri=${encodeURIComponent(redirectUri)}&response_type=code`;
8030
+ if (state !== void 0) {
8031
+ url += `&state=${encodeURIComponent(state)}`;
8032
+ }
8033
+ return url;
7116
8034
  };
8035
+ const normalizeTokenRequestError = (error) => sanitizeTokenError(error, "AniList token request");
7117
8036
  const requestToken = async (params, signal) => {
7118
8037
  const options = {
7119
- // Token exchanges block the login flow, so they fail faster than
7120
- // GraphQL operations unless the caller tunes the timeout explicitly.
7121
8038
  timeout: AUTH_TOKEN_TIMEOUT_MS,
7122
- signal
8039
+ signal,
8040
+ exposeRawAxiosError: false
7123
8041
  };
7124
8042
  try {
7125
- return await sendRequest(
7126
- ANILIST_TOKEN_URL,
7127
- "POST",
7128
- new URLSearchParams(params).toString(),
7129
- void 0,
7130
- false,
8043
+ const body = new URLSearchParams(params).toString();
8044
+ return await sendRequest(ANILIST_TOKEN_URL, "POST", body, void 0, {
8045
+ requiresAuth: false,
7131
8046
  options,
7132
- void 0,
7133
- "application/x-www-form-urlencoded"
7134
- );
8047
+ contentType: "application/x-www-form-urlencoded"
8048
+ });
7135
8049
  } catch (error) {
7136
8050
  throw normalizeTokenRequestError(error);
7137
8051
  }
@@ -7157,6 +8071,171 @@ const refreshAccessToken = async (clientId, clientSecret, refreshToken, signal)
7157
8071
  );
7158
8072
  const getTokenExpiry = (response, now = Date.now()) => new Date(now + response.expires_in * 1e3);
7159
8073
 
8074
+ const isPlainObject = (value) => {
8075
+ const proto = Object.getPrototypeOf(value);
8076
+ return proto === Object.prototype || proto === null;
8077
+ };
8078
+ const stableStringify = (value, seen = /* @__PURE__ */ new Set()) => {
8079
+ if (value === null || typeof value !== "object") {
8080
+ return JSON.stringify(value) ?? "undefined";
8081
+ }
8082
+ if (seen.has(value)) {
8083
+ return '"[Circular]"';
8084
+ }
8085
+ seen.add(value);
8086
+ try {
8087
+ if (Array.isArray(value)) {
8088
+ return `[${value.map((item) => stableStringify(item, seen)).join(",")}]`;
8089
+ }
8090
+ if (!isPlainObject(value)) {
8091
+ return JSON.stringify(value) ?? "undefined";
8092
+ }
8093
+ const keys = Object.keys(value).sort();
8094
+ return `{${keys.map(
8095
+ (key) => `${JSON.stringify(key)}:${stableStringify(value[key], seen)}`
8096
+ ).join(",")}}`;
8097
+ } finally {
8098
+ seen.delete(value);
8099
+ }
8100
+ };
8101
+ class ResponseCache {
8102
+ entries = /* @__PURE__ */ new Map();
8103
+ ttlMs;
8104
+ maxEntries;
8105
+ /**
8106
+ * Creates a response cache.
8107
+ *
8108
+ * @param options - Cache configuration; `ttlMs` defaults to 60_000, `maxEntries` to 128.
8109
+ */
8110
+ constructor(options) {
8111
+ const rawTtl = options?.ttlMs ?? 6e4;
8112
+ if (!Number.isFinite(rawTtl) || rawTtl < 0) {
8113
+ throw new TypeError("ttlMs must be a finite, non-negative number");
8114
+ }
8115
+ this.ttlMs = rawTtl;
8116
+ const rawMax = options?.maxEntries ?? 128;
8117
+ if (!Number.isFinite(rawMax) || rawMax <= 0 || !Number.isInteger(rawMax)) {
8118
+ throw new TypeError("maxEntries must be a finite, positive integer");
8119
+ }
8120
+ this.maxEntries = rawMax;
8121
+ }
8122
+ /**
8123
+ * Builds the cache key for a request.
8124
+ *
8125
+ * The serialized body is SHA-256 hashed (truncated to 16 hex chars)
8126
+ * before it enters the key, so a credential-bearing GET body is never
8127
+ * duplicated into the key string in plaintext — the key map retains
8128
+ * entries for up to the TTL, outliving the error paths the rest of the
8129
+ * library scrubs. The hash is deterministic, so equal bodies still share
8130
+ * one entry and different bodies still get different entries.
8131
+ *
8132
+ * @param method - The HTTP method.
8133
+ * @param url - The request URL.
8134
+ * @param data - The request body, when present.
8135
+ * @param authKey - An authentication-safe credential identity, so cached
8136
+ * responses never cross bearer-token identities.
8137
+ * @returns The cache key.
8138
+ */
8139
+ static buildKey(method, url, data, authKey) {
8140
+ const body = data === void 0 ? "none" : `sha256:${createHash("sha256").update(stableStringify(data)).digest("hex").slice(0, 16)}`;
8141
+ return `${method}:${url}:${body}:${authKey ?? "none"}`;
8142
+ }
8143
+ /**
8144
+ * Reads a cached response for the given request, or `undefined` when the
8145
+ * entry is absent or expired. Expired entries are evicted on read. The
8146
+ * returned value is a deep clone of the cached entry, so a caller that
8147
+ * mutates it cannot corrupt the cached copy or affect subsequent reads.
8148
+ *
8149
+ * @param method - The HTTP method.
8150
+ * @param url - The request URL.
8151
+ * @param data - The request body, when present.
8152
+ * @param authKey - An authentication-safe credential identity, so cached
8153
+ * responses never cross bearer-token identities.
8154
+ * @returns A deep clone of the cached response body, or `undefined`.
8155
+ */
8156
+ get(method, url, data, authKey) {
8157
+ const key = ResponseCache.buildKey(method, url, data, authKey);
8158
+ const entry = this.entries.get(key);
8159
+ if (entry === void 0) {
8160
+ return void 0;
8161
+ }
8162
+ if (Date.now() >= entry.expiresAt) {
8163
+ this.entries.delete(key);
8164
+ return void 0;
8165
+ }
8166
+ this.entries.delete(key);
8167
+ this.entries.set(key, entry);
8168
+ return structuredClone(entry.data);
8169
+ }
8170
+ /**
8171
+ * Stores a response in the cache, evicting the LRU entry when the cap is
8172
+ * reached. Only `GET` responses are cached; other methods are no-ops.
8173
+ * The value is deep-copied on write; the cache never aliases the
8174
+ * caller's object.
8175
+ *
8176
+ * @param method - The HTTP method.
8177
+ * @param url - The request URL.
8178
+ * @param data - The request body, when present.
8179
+ * @param authKey - An authentication-safe credential identity, so cached
8180
+ * responses never cross bearer-token identities.
8181
+ * @param response - The response body to cache.
8182
+ */
8183
+ set(method, url, data, authKey, response) {
8184
+ if (method !== "GET") return;
8185
+ const key = ResponseCache.buildKey(method, url, data, authKey);
8186
+ let snapshot;
8187
+ try {
8188
+ snapshot = structuredClone(response);
8189
+ } catch {
8190
+ return;
8191
+ }
8192
+ if (this.entries.has(key)) {
8193
+ this.entries.delete(key);
8194
+ } else if (this.entries.size >= this.maxEntries) {
8195
+ this.evictLru();
8196
+ }
8197
+ this.entries.set(key, {
8198
+ data: snapshot,
8199
+ expiresAt: Date.now() + this.ttlMs
8200
+ });
8201
+ }
8202
+ /**
8203
+ * Removes the cached entry for the given request, if present. Use this
8204
+ * for targeted invalidation after a mutation that changes the resource
8205
+ * (for example a `POST` that updates the entity a cached `GET` returned).
8206
+ * Only `GET` entries are tracked, so non-`GET` methods are a no-op and
8207
+ * return `false`.
8208
+ *
8209
+ * @param method - The HTTP method.
8210
+ * @param url - The request URL.
8211
+ * @param data - The request body, when present.
8212
+ * @param authKey - An authentication-safe credential identity, so cached
8213
+ * responses never cross bearer-token identities.
8214
+ * @returns `true` when an entry was removed, `false` when it was absent
8215
+ * or the method is not cached.
8216
+ */
8217
+ delete(method, url, data, authKey) {
8218
+ if (method !== "GET") return false;
8219
+ const key = ResponseCache.buildKey(method, url, data, authKey);
8220
+ return this.entries.delete(key);
8221
+ }
8222
+ /**
8223
+ * Evicts the least-recently-used entry.
8224
+ */
8225
+ evictLru() {
8226
+ const oldestKey = this.entries.keys().next().value;
8227
+ if (oldestKey !== void 0) {
8228
+ this.entries.delete(oldestKey);
8229
+ }
8230
+ }
8231
+ /**
8232
+ * Clears all cached entries.
8233
+ */
8234
+ clear() {
8235
+ this.entries.clear();
8236
+ }
8237
+ }
8238
+
7160
8239
  const MAL_AUTH_TIMEOUT_MS = 1e4;
7161
8240
  const buildMalAuthorizationUrl = (clientId, codeChallenge, state) => {
7162
8241
  const params = new URLSearchParams({
@@ -7168,53 +8247,19 @@ const buildMalAuthorizationUrl = (clientId, codeChallenge, state) => {
7168
8247
  if (state !== void 0) params.set("state", state);
7169
8248
  return `${MAL_AUTHORIZE_URL}?${params.toString().replaceAll("+", "%20")}`;
7170
8249
  };
7171
- const normalizeMalTokenError = (error) => {
7172
- if (error instanceof AniLinkApiError) {
7173
- error.message = `MAL token request failed with status ${error.status}.`;
7174
- return error;
7175
- }
7176
- if (error instanceof AniLinkError) return error;
7177
- if (axios.isCancel(error)) {
7178
- return new AniLinkNetworkError(
7179
- AniLinkErrorCodes.ABORTED,
7180
- "The MAL token request was cancelled."
7181
- );
7182
- }
7183
- if (axios.isAxiosError(error)) {
7184
- if (error.response?.status !== void 0) {
7185
- const apiError = new AniLinkApiError(error.response.status, error.response.data);
7186
- apiError.message = `MAL token request failed with status ${error.response.status}.`;
7187
- return apiError;
7188
- }
7189
- if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
7190
- return new AniLinkNetworkError(
7191
- AniLinkErrorCodes.TIMEOUT,
7192
- "The MAL token request timed out."
7193
- );
7194
- }
7195
- return new AniLinkNetworkError(
7196
- AniLinkErrorCodes.NETWORK,
7197
- "The MAL token request failed due to a network error."
7198
- );
7199
- }
7200
- return new AniLinkError("The MAL token request failed.", AniLinkErrorCodes.UNKNOWN);
7201
- };
8250
+ const normalizeMalTokenError = (error) => sanitizeTokenError(error, "MAL token request");
7202
8251
  const requestMalToken = async (params, options) => {
7203
8252
  try {
7204
- return await sendRequest(
7205
- MAL_TOKEN_URL,
7206
- "POST",
7207
- new URLSearchParams(params).toString(),
7208
- void 0,
7209
- false,
7210
- {
8253
+ const body = new URLSearchParams(params).toString();
8254
+ return await sendRequest(MAL_TOKEN_URL, "POST", body, void 0, {
8255
+ requiresAuth: false,
8256
+ options: {
7211
8257
  ...options,
7212
8258
  timeout: options?.timeout ?? MAL_AUTH_TIMEOUT_MS,
7213
8259
  exposeRawAxiosError: false
7214
8260
  },
7215
- void 0,
7216
- "application/x-www-form-urlencoded"
7217
- );
8261
+ contentType: "application/x-www-form-urlencoded"
8262
+ });
7218
8263
  } catch (error) {
7219
8264
  throw normalizeMalTokenError(error);
7220
8265
  }
@@ -7295,4 +8340,4 @@ class AniLink {
7295
8340
  }
7296
8341
  }
7297
8342
 
7298
- export { ANILIST_AUTHORIZE_URL, ANILIST_TOKEN_URL, AniLink, AniLinkApiError, AniLinkAuthError, AniLinkError, AniLinkErrorCodes, AniLinkGraphQLError, AniLinkNetworkError, AniLinkRestError, AniLinkValidationError, MAL_API_BASE_URL, MAL_API_REFERENCE, MAL_AUTHORIZE_URL, MAL_TOKEN_URL, buildAuthorizationUrl, buildMalAuthorizationUrl, buildMyAnimeListApi, buildProviderClients, getAccessToken, getMalAccessToken, getMalTokenExpiry, getTokenExpiry, paginate, paginateChunks, paginatePages, refreshAccessToken, refreshMalAccessToken };
8343
+ export { ANILIST_AUTHORIZE_URL, ANILIST_TOKEN_URL, AniLink, AniLinkApiError, AniLinkAuthError, AniLinkError, AniLinkErrorCodes, AniLinkGraphQLError, AniLinkNetworkError, AniLinkRestError, AniLinkValidationError, MAL_API_BASE_URL, MAL_API_REFERENCE, MAL_AUTHORIZE_URL, MAL_TOKEN_URL, ResponseCache, buildAuthorizationUrl, buildMalAuthorizationUrl, buildMyAnimeListApi, buildProviderClients, destroyCachedAgents, getAccessToken, getMalAccessToken, getMalTokenExpiry, getTokenExpiry, paginate, paginateChunks, paginatePages, refreshAccessToken, refreshMalAccessToken };