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/README.md +11 -11
- package/dist/AniLink.d.ts +770 -61
- package/dist/AniLink.mjs +1477 -432
- package/dist/anilist.d.ts +1 -1
- package/dist/anilist.mjs +1 -1
- package/dist/mal.d.ts +1 -1
- package/dist/mal.mjs +2 -2
- package/package.json +10 -5
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
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
193
|
-
|
|
206
|
+
};
|
|
207
|
+
const destroyCachedAgents = () => {
|
|
208
|
+
for (const pair of cachedAgentPairs.values()) {
|
|
209
|
+
pair.httpAgent.destroy();
|
|
210
|
+
pair.httpsAgent.destroy();
|
|
194
211
|
}
|
|
195
|
-
|
|
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
|
|
202
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
244
|
-
|
|
245
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 = {
|
|
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 ?
|
|
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
|
|
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
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
-
|
|
324
|
-
|
|
325
|
-
|
|
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
|
|
544
|
+
return void 0;
|
|
333
545
|
};
|
|
334
|
-
const
|
|
335
|
-
const
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
-
|
|
554
|
+
const unwrapped = unwrapSingleRootField(response);
|
|
555
|
+
return unwrapped === void 0 ? response : unwrapped;
|
|
358
556
|
};
|
|
359
|
-
|
|
360
|
-
|
|
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
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
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 (
|
|
401
|
-
|
|
402
|
-
state.windowEndsAt = Date.now() + budget.windowMs;
|
|
611
|
+
if (error instanceof AniLinkApiError) {
|
|
612
|
+
return error.status === 429 || error.status >= 500;
|
|
403
613
|
}
|
|
404
|
-
return
|
|
614
|
+
return false;
|
|
405
615
|
};
|
|
406
|
-
const
|
|
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
|
-
|
|
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
|
|
421
|
-
|
|
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
|
|
427
|
-
if (circuit === void 0 || breaker === void 0
|
|
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
|
-
|
|
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
|
|
440
|
-
|
|
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
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
461
|
-
|
|
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
|
|
465
|
-
if (
|
|
466
|
-
|
|
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
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
492
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
515
|
-
|
|
516
|
-
|
|
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
|
-
|
|
520
|
-
|
|
521
|
-
|
|
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(
|
|
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,
|
|
539
|
-
const
|
|
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
|
-
|
|
560
|
-
options,
|
|
561
|
-
|
|
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
|
|
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,
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
this
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
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
|
|
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(
|
|
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(
|
|
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
|
|
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,
|
|
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,
|
|
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
|
|
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,
|
|
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
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
startPage,
|
|
1010
|
-
maxPages,
|
|
1011
|
-
concurrency
|
|
1563
|
+
const concurrency = resolveCappedInt(
|
|
1564
|
+
options?.concurrency,
|
|
1565
|
+
MAX_CONCURRENCY,
|
|
1566
|
+
DEFAULT_CONCURRENCY
|
|
1012
1567
|
);
|
|
1013
|
-
const
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
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
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
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
|
-
|
|
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(
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
startChunk,
|
|
1047
|
-
maxChunks,
|
|
1048
|
-
concurrency
|
|
1657
|
+
const concurrency = resolveCappedInt(
|
|
1658
|
+
options?.concurrency,
|
|
1659
|
+
MAX_CONCURRENCY,
|
|
1660
|
+
DEFAULT_CONCURRENCY
|
|
1049
1661
|
);
|
|
1050
|
-
const
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
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
|
|
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
|
|
6856
|
-
|
|
6857
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
6877
|
-
|
|
6878
|
-
|
|
6879
|
-
|
|
6880
|
-
|
|
6881
|
-
|
|
6882
|
-
|
|
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
|
-
|
|
6885
|
-
|
|
6886
|
-
|
|
6887
|
-
|
|
6888
|
-
|
|
6889
|
-
|
|
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
|
|
6899
|
-
|
|
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
|
|
6948
|
-
*
|
|
6949
|
-
* REST providers have no GraphQL-style envelope, so no unwrapping
|
|
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 = {}
|
|
6961
|
-
const {
|
|
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
|
-
|
|
7009
|
-
{
|
|
7010
|
-
|
|
7011
|
-
|
|
7012
|
-
|
|
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
|
-
|
|
7039
|
-
|
|
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: {
|
|
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(
|
|
7067
|
-
mal: PROVIDER_FACTORIES.mal(
|
|
7975
|
+
anilist: PROVIDER_FACTORIES.anilist(anilistSlot, legacyOptions),
|
|
7976
|
+
mal: PROVIDER_FACTORIES.mal(malSlot)
|
|
7068
7977
|
};
|
|
7069
7978
|
}
|
|
7070
7979
|
|
|
7071
|
-
const
|
|
7072
|
-
|
|
7073
|
-
const
|
|
7074
|
-
|
|
7075
|
-
|
|
7076
|
-
|
|
7077
|
-
|
|
7078
|
-
|
|
7079
|
-
|
|
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
|
-
|
|
7086
|
-
|
|
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 =
|
|
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
|
-
|
|
8017
|
+
`${label} failed due to a network error.`
|
|
7113
8018
|
);
|
|
7114
8019
|
}
|
|
7115
|
-
return new AniLinkError(
|
|
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
|
-
|
|
7126
|
-
|
|
7127
|
-
|
|
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
|
-
|
|
7133
|
-
|
|
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
|
-
|
|
7205
|
-
|
|
7206
|
-
|
|
7207
|
-
|
|
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
|
-
|
|
7216
|
-
|
|
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 };
|