anilink-api-wrapper 3.0.0 → 3.1.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 +24 -13
- package/dist/AniLink.d.ts +2119 -1505
- package/dist/AniLink.mjs +2011 -1257
- package/dist/anilist.d.ts +1 -1
- package/dist/anilist.mjs +2 -2
- package/dist/mal.d.ts +1 -1
- package/dist/mal.mjs +1 -1
- package/package.json +32 -21
package/dist/AniLink.mjs
CHANGED
|
@@ -1,79 +1,128 @@
|
|
|
1
|
-
import { randomInt, createHash, randomUUID } from 'node:crypto';
|
|
2
1
|
import http from 'node:http';
|
|
3
2
|
import https from 'node:https';
|
|
4
3
|
import axios from 'axios';
|
|
4
|
+
import { randomInt, createHash, randomUUID } from 'node:crypto';
|
|
5
5
|
|
|
6
|
-
const
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
"
|
|
11
|
-
"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
"onRetry",
|
|
19
|
-
"onRequestStart",
|
|
20
|
-
"onResponse",
|
|
21
|
-
"onPace",
|
|
22
|
-
"onHookError",
|
|
23
|
-
"diagnostics",
|
|
24
|
-
"onCircuitOpen",
|
|
25
|
-
"onCircuitClose",
|
|
26
|
-
"ignorePaceDeadline",
|
|
27
|
-
"allowPartialData",
|
|
28
|
-
"responseCache"
|
|
29
|
-
];
|
|
6
|
+
const DEFAULT_REQUEST_TIMEOUT = 3e4;
|
|
7
|
+
const MAX_FREE_SOCKETS = 5;
|
|
8
|
+
const MAX_SOCKETS = 20;
|
|
9
|
+
const resolveDiagnosticsMode = (diagnostics) => {
|
|
10
|
+
const resolved = diagnostics ?? "warn";
|
|
11
|
+
if (resolved !== "warn" && resolved !== "hook" && resolved !== "silent") {
|
|
12
|
+
throw new TypeError(
|
|
13
|
+
`Invalid diagnostics ${JSON.stringify(resolved)}: it must be one of "warn", "hook", or "silent".`
|
|
14
|
+
);
|
|
15
|
+
}
|
|
16
|
+
return resolved;
|
|
17
|
+
};
|
|
30
18
|
|
|
31
|
-
const
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
19
|
+
const DEFAULT_AGENT_OPTIONS = {
|
|
20
|
+
keepAlive: true,
|
|
21
|
+
maxSockets: MAX_SOCKETS,
|
|
22
|
+
maxFreeSockets: MAX_FREE_SOCKETS,
|
|
23
|
+
scheduling: "lifo"
|
|
24
|
+
};
|
|
25
|
+
let defaultHttpAgent = new http.Agent(DEFAULT_AGENT_OPTIONS);
|
|
26
|
+
let defaultHttpsAgent = new https.Agent(DEFAULT_AGENT_OPTIONS);
|
|
27
|
+
let axiosClient = axios.create({
|
|
28
|
+
timeout: DEFAULT_REQUEST_TIMEOUT,
|
|
29
|
+
httpAgent: defaultHttpAgent,
|
|
30
|
+
httpsAgent: defaultHttpsAgent
|
|
31
|
+
});
|
|
32
|
+
let defaultAgentsTornDown = false;
|
|
33
|
+
const ensureDefaultAgents = () => {
|
|
34
|
+
if (!defaultAgentsTornDown) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
defaultHttpAgent = new http.Agent(DEFAULT_AGENT_OPTIONS);
|
|
38
|
+
defaultHttpsAgent = new https.Agent(DEFAULT_AGENT_OPTIONS);
|
|
39
|
+
axiosClient = axios.create({
|
|
40
|
+
timeout: DEFAULT_REQUEST_TIMEOUT,
|
|
41
|
+
httpAgent: defaultHttpAgent,
|
|
42
|
+
httpsAgent: defaultHttpsAgent
|
|
43
|
+
});
|
|
44
|
+
defaultAgentsTornDown = false;
|
|
45
|
+
};
|
|
46
|
+
const MAX_CACHED_AGENT_PAIRS = 8;
|
|
47
|
+
const MAX_PARKED_EVICTED_PAIRS = 16;
|
|
48
|
+
const cachedAgentPairs = /* @__PURE__ */ new Map();
|
|
49
|
+
const parkedEvictedPairs = [];
|
|
50
|
+
const buildAgentCacheKey = (maxSockets, maxFreeSockets) => `${maxSockets}:${maxFreeSockets}`;
|
|
51
|
+
const evictLruAgentPair = () => {
|
|
52
|
+
const oldestKey = cachedAgentPairs.keys().next().value;
|
|
53
|
+
if (oldestKey !== void 0) {
|
|
54
|
+
const evicted = cachedAgentPairs.get(oldestKey);
|
|
55
|
+
cachedAgentPairs.delete(oldestKey);
|
|
56
|
+
if (evicted !== void 0) {
|
|
57
|
+
if (parkedEvictedPairs.length >= MAX_PARKED_EVICTED_PAIRS) {
|
|
58
|
+
const oldestParked = parkedEvictedPairs.shift();
|
|
59
|
+
if (oldestParked !== void 0) {
|
|
60
|
+
oldestParked.httpAgent.destroy();
|
|
61
|
+
oldestParked.httpsAgent.destroy();
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
parkedEvictedPairs.push(evicted);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
const destroyCachedAgents = () => {
|
|
69
|
+
for (const pair of cachedAgentPairs.values()) {
|
|
70
|
+
pair.httpAgent.destroy();
|
|
71
|
+
pair.httpsAgent.destroy();
|
|
72
|
+
}
|
|
73
|
+
cachedAgentPairs.clear();
|
|
74
|
+
for (const pair of parkedEvictedPairs) {
|
|
75
|
+
pair.httpAgent.destroy();
|
|
76
|
+
pair.httpsAgent.destroy();
|
|
77
|
+
}
|
|
78
|
+
parkedEvictedPairs.length = 0;
|
|
79
|
+
if (!defaultAgentsTornDown) {
|
|
80
|
+
defaultHttpAgent.destroy();
|
|
81
|
+
defaultHttpsAgent.destroy();
|
|
82
|
+
defaultAgentsTornDown = true;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
const resolveAgents = (maxSockets, maxFreeSockets) => {
|
|
86
|
+
ensureDefaultAgents();
|
|
87
|
+
if (maxSockets === void 0 && maxFreeSockets === void 0) {
|
|
88
|
+
return { httpAgent: defaultHttpAgent, httpsAgent: defaultHttpsAgent };
|
|
89
|
+
}
|
|
90
|
+
const normalizeSockets = (value, fallback, min) => {
|
|
91
|
+
if (value === void 0) {
|
|
92
|
+
return fallback;
|
|
93
|
+
}
|
|
94
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
38
95
|
throw new TypeError(
|
|
39
|
-
`
|
|
40
|
-
", "
|
|
41
|
-
)}. Provider auth fields are: ${providerFields.join(", ")}.`
|
|
96
|
+
`Invalid socket bound ${value}: maxSockets/maxFreeSockets must be finite, non-negative integers.`
|
|
42
97
|
);
|
|
43
98
|
}
|
|
44
|
-
|
|
99
|
+
return Math.max(min, value);
|
|
100
|
+
};
|
|
101
|
+
const sockets = normalizeSockets(maxSockets, MAX_SOCKETS, 1);
|
|
102
|
+
const freeSockets = normalizeSockets(maxFreeSockets, MAX_FREE_SOCKETS, 0);
|
|
103
|
+
const key = buildAgentCacheKey(sockets, freeSockets);
|
|
104
|
+
const cached = cachedAgentPairs.get(key);
|
|
105
|
+
if (cached !== void 0) {
|
|
106
|
+
cachedAgentPairs.delete(key);
|
|
107
|
+
cachedAgentPairs.set(key, cached);
|
|
108
|
+
return { httpAgent: cached.httpAgent, httpsAgent: cached.httpsAgent };
|
|
45
109
|
}
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
"refreshToken",
|
|
55
|
-
"clientId",
|
|
56
|
-
"clientSecret",
|
|
57
|
-
"onTokenRefresh",
|
|
58
|
-
"onTokenRefreshError"
|
|
59
|
-
])
|
|
110
|
+
if (cachedAgentPairs.size >= MAX_CACHED_AGENT_PAIRS) {
|
|
111
|
+
evictLruAgentPair();
|
|
112
|
+
}
|
|
113
|
+
const agentOptions = {
|
|
114
|
+
keepAlive: true,
|
|
115
|
+
maxSockets: sockets,
|
|
116
|
+
maxFreeSockets: freeSockets,
|
|
117
|
+
scheduling: "lifo"
|
|
60
118
|
};
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
const headers = credentials.clientId !== void 0 && credentials.accessToken === void 0 ? { "X-MAL-CLIENT-ID": credentials.clientId } : void 0;
|
|
65
|
-
return {
|
|
66
|
-
auth: credentials.accessToken === void 0 && headers === void 0 ? void 0 : { token: credentials.accessToken, headers },
|
|
67
|
-
options: resolveTransportOptions(credentials, [
|
|
68
|
-
"accessToken",
|
|
69
|
-
"refreshToken",
|
|
70
|
-
"clientId",
|
|
71
|
-
"clientSecret",
|
|
72
|
-
"onTokenRefresh",
|
|
73
|
-
"onTokenRefreshError"
|
|
74
|
-
])
|
|
119
|
+
const pair = {
|
|
120
|
+
httpAgent: new http.Agent(agentOptions),
|
|
121
|
+
httpsAgent: new https.Agent(agentOptions)
|
|
75
122
|
};
|
|
76
|
-
|
|
123
|
+
cachedAgentPairs.set(key, pair);
|
|
124
|
+
return { httpAgent: pair.httpAgent, httpsAgent: pair.httpsAgent };
|
|
125
|
+
};
|
|
77
126
|
|
|
78
127
|
const AniLinkErrorCodes = {
|
|
79
128
|
API: "API_ERROR",
|
|
@@ -245,111 +294,6 @@ class AniLinkNetworkError extends AniLinkError {
|
|
|
245
294
|
}
|
|
246
295
|
}
|
|
247
296
|
|
|
248
|
-
const DEFAULT_REQUEST_TIMEOUT = 3e4;
|
|
249
|
-
const MAX_FREE_SOCKETS = 5;
|
|
250
|
-
const MAX_SOCKETS = 20;
|
|
251
|
-
const resolveDiagnosticsMode = (diagnostics) => {
|
|
252
|
-
const resolved = diagnostics ?? "warn";
|
|
253
|
-
if (resolved !== "warn" && resolved !== "hook" && resolved !== "silent") {
|
|
254
|
-
throw new TypeError(
|
|
255
|
-
`Invalid diagnostics ${JSON.stringify(resolved)}: it must be one of "warn", "hook", or "silent".`
|
|
256
|
-
);
|
|
257
|
-
}
|
|
258
|
-
return resolved;
|
|
259
|
-
};
|
|
260
|
-
|
|
261
|
-
const defaultHttpAgent = new http.Agent({
|
|
262
|
-
keepAlive: true,
|
|
263
|
-
maxSockets: MAX_SOCKETS,
|
|
264
|
-
maxFreeSockets: MAX_FREE_SOCKETS,
|
|
265
|
-
scheduling: "lifo"
|
|
266
|
-
});
|
|
267
|
-
const defaultHttpsAgent = new https.Agent({
|
|
268
|
-
keepAlive: true,
|
|
269
|
-
maxSockets: MAX_SOCKETS,
|
|
270
|
-
maxFreeSockets: MAX_FREE_SOCKETS,
|
|
271
|
-
scheduling: "lifo"
|
|
272
|
-
});
|
|
273
|
-
const axiosClient = axios.create({
|
|
274
|
-
timeout: DEFAULT_REQUEST_TIMEOUT,
|
|
275
|
-
httpAgent: defaultHttpAgent,
|
|
276
|
-
httpsAgent: defaultHttpsAgent
|
|
277
|
-
});
|
|
278
|
-
const MAX_CACHED_AGENT_PAIRS = 8;
|
|
279
|
-
const MAX_PARKED_EVICTED_PAIRS = 16;
|
|
280
|
-
const cachedAgentPairs = /* @__PURE__ */ new Map();
|
|
281
|
-
const parkedEvictedPairs = [];
|
|
282
|
-
const buildAgentCacheKey = (maxSockets, maxFreeSockets) => `${maxSockets}:${maxFreeSockets}`;
|
|
283
|
-
const evictLruAgentPair = () => {
|
|
284
|
-
const oldestKey = cachedAgentPairs.keys().next().value;
|
|
285
|
-
if (oldestKey !== void 0) {
|
|
286
|
-
const evicted = cachedAgentPairs.get(oldestKey);
|
|
287
|
-
cachedAgentPairs.delete(oldestKey);
|
|
288
|
-
if (evicted !== void 0) {
|
|
289
|
-
if (parkedEvictedPairs.length >= MAX_PARKED_EVICTED_PAIRS) {
|
|
290
|
-
const oldestParked = parkedEvictedPairs.shift();
|
|
291
|
-
if (oldestParked !== void 0) {
|
|
292
|
-
oldestParked.httpAgent.destroy();
|
|
293
|
-
oldestParked.httpsAgent.destroy();
|
|
294
|
-
}
|
|
295
|
-
}
|
|
296
|
-
parkedEvictedPairs.push(evicted);
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
};
|
|
300
|
-
const destroyCachedAgents = () => {
|
|
301
|
-
for (const pair of cachedAgentPairs.values()) {
|
|
302
|
-
pair.httpAgent.destroy();
|
|
303
|
-
pair.httpsAgent.destroy();
|
|
304
|
-
}
|
|
305
|
-
cachedAgentPairs.clear();
|
|
306
|
-
for (const pair of parkedEvictedPairs) {
|
|
307
|
-
pair.httpAgent.destroy();
|
|
308
|
-
pair.httpsAgent.destroy();
|
|
309
|
-
}
|
|
310
|
-
parkedEvictedPairs.length = 0;
|
|
311
|
-
};
|
|
312
|
-
const resolveAgents = (maxSockets, maxFreeSockets) => {
|
|
313
|
-
if (maxSockets === void 0 && maxFreeSockets === void 0) {
|
|
314
|
-
return { httpAgent: defaultHttpAgent, httpsAgent: defaultHttpsAgent };
|
|
315
|
-
}
|
|
316
|
-
const normalizeSockets = (value, fallback, min) => {
|
|
317
|
-
if (value === void 0) {
|
|
318
|
-
return fallback;
|
|
319
|
-
}
|
|
320
|
-
if (!Number.isFinite(value) || !Number.isInteger(value) || value < 0) {
|
|
321
|
-
throw new TypeError(
|
|
322
|
-
`Invalid socket bound ${value}: maxSockets/maxFreeSockets must be finite, non-negative integers.`
|
|
323
|
-
);
|
|
324
|
-
}
|
|
325
|
-
return Math.max(min, value);
|
|
326
|
-
};
|
|
327
|
-
const sockets = normalizeSockets(maxSockets, MAX_SOCKETS, 1);
|
|
328
|
-
const freeSockets = normalizeSockets(maxFreeSockets, MAX_FREE_SOCKETS, 0);
|
|
329
|
-
const key = buildAgentCacheKey(sockets, freeSockets);
|
|
330
|
-
const cached = cachedAgentPairs.get(key);
|
|
331
|
-
if (cached !== void 0) {
|
|
332
|
-
cachedAgentPairs.delete(key);
|
|
333
|
-
cachedAgentPairs.set(key, cached);
|
|
334
|
-
return { httpAgent: cached.httpAgent, httpsAgent: cached.httpsAgent };
|
|
335
|
-
}
|
|
336
|
-
if (cachedAgentPairs.size >= MAX_CACHED_AGENT_PAIRS) {
|
|
337
|
-
evictLruAgentPair();
|
|
338
|
-
}
|
|
339
|
-
const agentOptions = {
|
|
340
|
-
keepAlive: true,
|
|
341
|
-
maxSockets: sockets,
|
|
342
|
-
maxFreeSockets: freeSockets,
|
|
343
|
-
scheduling: "lifo"
|
|
344
|
-
};
|
|
345
|
-
const pair = {
|
|
346
|
-
httpAgent: new http.Agent(agentOptions),
|
|
347
|
-
httpsAgent: new https.Agent(agentOptions)
|
|
348
|
-
};
|
|
349
|
-
cachedAgentPairs.set(key, pair);
|
|
350
|
-
return { httpAgent: pair.httpAgent, httpsAgent: pair.httpsAgent };
|
|
351
|
-
};
|
|
352
|
-
|
|
353
297
|
const MAX_RETRY_AFTER_MS = 6e4;
|
|
354
298
|
const DEFAULT_RETRY_POLICY = {
|
|
355
299
|
maxRetries: 3,
|
|
@@ -476,6 +420,19 @@ const computeNextRetryDelay = (input) => {
|
|
|
476
420
|
}
|
|
477
421
|
return getRetryDelay(normalized, rawError, attempt, policy);
|
|
478
422
|
};
|
|
423
|
+
const isBudgetGatedFailure = (input) => {
|
|
424
|
+
const { normalized, rawError, attempt, policy, budgetState, budget, wasProbe } = input;
|
|
425
|
+
if (wasProbe || policy === null) {
|
|
426
|
+
return false;
|
|
427
|
+
}
|
|
428
|
+
if (budgetState === void 0 || budget === void 0) {
|
|
429
|
+
return false;
|
|
430
|
+
}
|
|
431
|
+
if (budgetState.retriesUsed < budget.maxRetriesPerWindow) {
|
|
432
|
+
return false;
|
|
433
|
+
}
|
|
434
|
+
return getRetryDelay(normalized, rawError, attempt, policy) !== null;
|
|
435
|
+
};
|
|
479
436
|
const retryBudgetStates = /* @__PURE__ */ new WeakMap();
|
|
480
437
|
const getRetryBudgetState = (owner, budget) => {
|
|
481
438
|
if (owner === void 0 || budget === void 0) {
|
|
@@ -529,8 +486,184 @@ const resolveRequestOptions = (options = {}) => {
|
|
|
529
486
|
onCircuitClose: options.onCircuitClose,
|
|
530
487
|
ignorePaceDeadline: options.ignorePaceDeadline ?? false,
|
|
531
488
|
allowPartialData: options.allowPartialData ?? false,
|
|
532
|
-
responseCache: options.responseCache
|
|
489
|
+
responseCache: options.responseCache,
|
|
490
|
+
bypassResponseCache: options.bypassResponseCache ?? false
|
|
491
|
+
};
|
|
492
|
+
};
|
|
493
|
+
const DEEP_MERGED_OPTION_KEYS = ["retry", "circuitBreaker", "retryBudget"];
|
|
494
|
+
const assignDeepMergedOption = (merged, key, value) => {
|
|
495
|
+
merged[key] = value;
|
|
496
|
+
};
|
|
497
|
+
const mergeOptions = (base, overrides) => {
|
|
498
|
+
if (overrides === void 0) return base;
|
|
499
|
+
if (base === void 0) return overrides;
|
|
500
|
+
const merged = { ...base, ...overrides };
|
|
501
|
+
for (const key of DEEP_MERGED_OPTION_KEYS) {
|
|
502
|
+
const baseValue = base[key];
|
|
503
|
+
const overrideValue = overrides[key];
|
|
504
|
+
if (typeof baseValue === "object" && baseValue !== null && typeof overrideValue === "object" && overrideValue !== null) {
|
|
505
|
+
assignDeepMergedOption(merged, key, { ...baseValue, ...overrideValue });
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return merged;
|
|
509
|
+
};
|
|
510
|
+
const TRANSPORT_OPTION_KEYS = [
|
|
511
|
+
"timeout",
|
|
512
|
+
"signal",
|
|
513
|
+
"exposeRawAxiosError",
|
|
514
|
+
"retry",
|
|
515
|
+
"paceWithRateLimit",
|
|
516
|
+
"rateLimitFloor",
|
|
517
|
+
"circuitBreaker",
|
|
518
|
+
"retryBudget",
|
|
519
|
+
"maxSockets",
|
|
520
|
+
"maxFreeSockets",
|
|
521
|
+
"onError",
|
|
522
|
+
"onRetry",
|
|
523
|
+
"onRequestStart",
|
|
524
|
+
"onResponse",
|
|
525
|
+
"onPace",
|
|
526
|
+
"onHookError",
|
|
527
|
+
"diagnostics",
|
|
528
|
+
"onCircuitOpen",
|
|
529
|
+
"onCircuitClose",
|
|
530
|
+
"ignorePaceDeadline",
|
|
531
|
+
"allowPartialData",
|
|
532
|
+
"responseCache",
|
|
533
|
+
"bypassResponseCache"
|
|
534
|
+
];
|
|
535
|
+
|
|
536
|
+
const isNonBlank = (value) => typeof value === "string" && value.trim() !== "";
|
|
537
|
+
const resolveTransportOptions = (credentials, providerFields) => {
|
|
538
|
+
const allowedKeys = /* @__PURE__ */ new Set([...TRANSPORT_OPTION_KEYS, ...providerFields]);
|
|
539
|
+
const options = {};
|
|
540
|
+
for (const [key, value] of Object.entries(credentials)) {
|
|
541
|
+
if (providerFields.includes(key)) continue;
|
|
542
|
+
if (!allowedKeys.has(key)) {
|
|
543
|
+
throw new TypeError(
|
|
544
|
+
`Unknown credential key "${key}". Valid transport options are: ${TRANSPORT_OPTION_KEYS.join(
|
|
545
|
+
", "
|
|
546
|
+
)}. Provider auth fields are: ${providerFields.join(", ")}.`
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
options[key] = value;
|
|
550
|
+
}
|
|
551
|
+
return Object.keys(options).length === 0 ? void 0 : options;
|
|
552
|
+
};
|
|
553
|
+
function resolveAniListCredentials(credentials) {
|
|
554
|
+
if (credentials === void 0) return {};
|
|
555
|
+
return {
|
|
556
|
+
auth: credentials.authToken,
|
|
557
|
+
options: resolveTransportOptions(credentials, [
|
|
558
|
+
"authToken",
|
|
559
|
+
"refreshToken",
|
|
560
|
+
"clientId",
|
|
561
|
+
"clientSecret",
|
|
562
|
+
"onTokenRefresh",
|
|
563
|
+
"onTokenRefreshError"
|
|
564
|
+
])
|
|
533
565
|
};
|
|
566
|
+
}
|
|
567
|
+
function resolveMalCredentials(credentials) {
|
|
568
|
+
if (credentials === void 0) return {};
|
|
569
|
+
const headers = credentials.clientId !== void 0 && credentials.accessToken === void 0 ? { "X-MAL-CLIENT-ID": credentials.clientId } : void 0;
|
|
570
|
+
return {
|
|
571
|
+
auth: credentials.accessToken === void 0 && headers === void 0 ? void 0 : { token: credentials.accessToken, headers },
|
|
572
|
+
options: resolveTransportOptions(credentials, [
|
|
573
|
+
"accessToken",
|
|
574
|
+
"refreshToken",
|
|
575
|
+
"clientId",
|
|
576
|
+
"clientSecret",
|
|
577
|
+
"onTokenRefresh",
|
|
578
|
+
"onTokenRefreshError"
|
|
579
|
+
])
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
const buildDiagnostic = (kind, hookName, message, requestId) => ({
|
|
584
|
+
source: "anilink",
|
|
585
|
+
kind,
|
|
586
|
+
hookName,
|
|
587
|
+
...requestId === void 0 ? {} : { requestId },
|
|
588
|
+
message
|
|
589
|
+
});
|
|
590
|
+
const reportDiagnostic = (options) => {
|
|
591
|
+
const { kind, hookName, message, requestId, onHookError, diagnostics, rawError, rethrown } = options;
|
|
592
|
+
if (onHookError !== void 0 && (rawError !== void 0 || diagnostics !== "silent")) {
|
|
593
|
+
const record = buildDiagnostic(kind, hookName, message, requestId);
|
|
594
|
+
try {
|
|
595
|
+
onHookError(
|
|
596
|
+
hookName,
|
|
597
|
+
new Error(message, {
|
|
598
|
+
cause: rawError !== void 0 ? rawError : record
|
|
599
|
+
})
|
|
600
|
+
);
|
|
601
|
+
} catch {
|
|
602
|
+
}
|
|
603
|
+
return true;
|
|
604
|
+
}
|
|
605
|
+
if (rethrown === true || diagnostics !== "warn") {
|
|
606
|
+
return false;
|
|
607
|
+
}
|
|
608
|
+
console.warn(JSON.stringify(buildDiagnostic(kind, hookName, message, requestId)));
|
|
609
|
+
return true;
|
|
610
|
+
};
|
|
611
|
+
const safeInvoke = (hook, name, onHookError, diagnostics, ...args) => {
|
|
612
|
+
if (hook === void 0) {
|
|
613
|
+
return;
|
|
614
|
+
}
|
|
615
|
+
try {
|
|
616
|
+
hook(...args);
|
|
617
|
+
} catch (hookError) {
|
|
618
|
+
const firstArg = args[0];
|
|
619
|
+
const requestId = firstArg !== null && typeof firstArg === "object" && "requestId" in firstArg && typeof firstArg.requestId === "string" ? firstArg.requestId : void 0;
|
|
620
|
+
const detail = hookError instanceof Error ? hookError.message : String(hookError);
|
|
621
|
+
reportDiagnostic({
|
|
622
|
+
kind: "hook-failure",
|
|
623
|
+
hookName: name,
|
|
624
|
+
message: `The ${name} hook threw and was ignored: ${detail}`,
|
|
625
|
+
requestId,
|
|
626
|
+
onHookError,
|
|
627
|
+
diagnostics,
|
|
628
|
+
rawError: hookError
|
|
629
|
+
});
|
|
630
|
+
}
|
|
631
|
+
};
|
|
632
|
+
const buildErrorContext = (requestId, url, method, attempt, normalized, facts = {}) => ({
|
|
633
|
+
requestId,
|
|
634
|
+
url,
|
|
635
|
+
method,
|
|
636
|
+
attempt,
|
|
637
|
+
code: normalized.code,
|
|
638
|
+
...normalized instanceof AniLinkApiError ? { status: normalized.status } : {},
|
|
639
|
+
...normalized instanceof AniLinkApiError && normalized.rateLimit !== void 0 ? { rateLimit: normalized.rateLimit } : {},
|
|
640
|
+
...facts.nextDelayMs === void 0 ? {} : { nextDelayMs: facts.nextDelayMs },
|
|
641
|
+
...facts.retryWaitMs === void 0 || facts.retryWaitMs <= 0 ? {} : { retryWaitMs: facts.retryWaitMs },
|
|
642
|
+
...facts.budgetExhausted ? { budgetExhausted: true } : {},
|
|
643
|
+
...facts.host === void 0 ? {} : { host: facts.host },
|
|
644
|
+
...facts.retryAfterMs === void 0 ? {} : { retryAfterMs: facts.retryAfterMs }
|
|
645
|
+
});
|
|
646
|
+
const reportFailure = (requestId, url, method, attempt, normalized, resolved, facts = {}) => {
|
|
647
|
+
const context = buildErrorContext(requestId, url, method, attempt, normalized, facts);
|
|
648
|
+
if (facts.nextDelayMs !== void 0) {
|
|
649
|
+
safeInvoke(
|
|
650
|
+
resolved.onRetry ?? resolved.onError,
|
|
651
|
+
resolved.onRetry === void 0 ? "onError" : "onRetry",
|
|
652
|
+
resolved.onHookError,
|
|
653
|
+
resolved.diagnostics,
|
|
654
|
+
normalized,
|
|
655
|
+
context
|
|
656
|
+
);
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
safeInvoke(
|
|
660
|
+
resolved.onError,
|
|
661
|
+
"onError",
|
|
662
|
+
resolved.onHookError,
|
|
663
|
+
resolved.diagnostics,
|
|
664
|
+
normalized,
|
|
665
|
+
context
|
|
666
|
+
);
|
|
534
667
|
};
|
|
535
668
|
|
|
536
669
|
const GRAPHQL_QUERY_PATTERN = /^\s*(?:\{|query\b[\s\S]*\{)/;
|
|
@@ -592,6 +725,176 @@ const resolveCacheAuthKey = (cache, method, data, isRestCall, auth) => {
|
|
|
592
725
|
}
|
|
593
726
|
return buildCacheAuthKey(auth.hasBearerToken, auth.hasCredentialHeaders, auth.auth?.token);
|
|
594
727
|
};
|
|
728
|
+
const extractRootField = (document) => {
|
|
729
|
+
const keyword = /^\s*(query|mutation|subscription|fragment)\b/.exec(document);
|
|
730
|
+
let rest = keyword === null ? document : document.slice(keyword[0].length);
|
|
731
|
+
const name = /^\s*[A-Za-z_][A-Za-z0-9_]*/.exec(rest);
|
|
732
|
+
if (name !== null && name[0].trim() !== "") {
|
|
733
|
+
rest = rest.slice(name[0].length);
|
|
734
|
+
}
|
|
735
|
+
if (/^\s*\(/.test(rest)) {
|
|
736
|
+
let depth = 0;
|
|
737
|
+
let end = -1;
|
|
738
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
739
|
+
const ch = rest[i];
|
|
740
|
+
if (ch === "(") {
|
|
741
|
+
depth += 1;
|
|
742
|
+
} else if (ch === ")") {
|
|
743
|
+
depth -= 1;
|
|
744
|
+
if (depth === 0) {
|
|
745
|
+
end = i + 1;
|
|
746
|
+
break;
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
}
|
|
750
|
+
if (end === -1) {
|
|
751
|
+
return void 0;
|
|
752
|
+
}
|
|
753
|
+
rest = rest.slice(end);
|
|
754
|
+
}
|
|
755
|
+
const openBrace = rest.indexOf("{");
|
|
756
|
+
if (openBrace === -1) {
|
|
757
|
+
return void 0;
|
|
758
|
+
}
|
|
759
|
+
const inside = rest.slice(openBrace + 1);
|
|
760
|
+
if (/^\s*\.\.\./.test(inside)) {
|
|
761
|
+
return void 0;
|
|
762
|
+
}
|
|
763
|
+
let cursor = inside;
|
|
764
|
+
let selection = /^\s*[A-Za-z_][A-Za-z0-9_]*/.exec(cursor);
|
|
765
|
+
if (selection === null) {
|
|
766
|
+
return void 0;
|
|
767
|
+
}
|
|
768
|
+
let fieldName = selection[0].trim();
|
|
769
|
+
cursor = cursor.slice(selection[0].length);
|
|
770
|
+
const colon = /^\s*:/.exec(cursor);
|
|
771
|
+
if (colon !== null) {
|
|
772
|
+
selection = /^\s*[A-Za-z_][A-Za-z0-9_]*/.exec(cursor.slice(colon[0].length));
|
|
773
|
+
if (selection === null) {
|
|
774
|
+
return void 0;
|
|
775
|
+
}
|
|
776
|
+
fieldName = selection[0].trim();
|
|
777
|
+
cursor = cursor.slice(colon[0].length + selection[0].length);
|
|
778
|
+
}
|
|
779
|
+
if (/^\s*\(/.test(cursor)) {
|
|
780
|
+
const openParen = cursor.indexOf("(");
|
|
781
|
+
let depth = 0;
|
|
782
|
+
let end = -1;
|
|
783
|
+
for (let i = openParen; i < cursor.length; i += 1) {
|
|
784
|
+
const ch = cursor[i];
|
|
785
|
+
if (ch === "(") {
|
|
786
|
+
depth += 1;
|
|
787
|
+
} else if (ch === ")") {
|
|
788
|
+
depth -= 1;
|
|
789
|
+
if (depth === 0) {
|
|
790
|
+
end = i + 1;
|
|
791
|
+
break;
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
if (end === -1) {
|
|
796
|
+
return void 0;
|
|
797
|
+
}
|
|
798
|
+
cursor = cursor.slice(end);
|
|
799
|
+
}
|
|
800
|
+
if (/^\s*\{/.test(cursor)) {
|
|
801
|
+
const openBraceIdx = cursor.indexOf("{");
|
|
802
|
+
let depth = 0;
|
|
803
|
+
let end = -1;
|
|
804
|
+
for (let i = openBraceIdx; i < cursor.length; i += 1) {
|
|
805
|
+
const ch = cursor[i];
|
|
806
|
+
if (ch === "{") {
|
|
807
|
+
depth += 1;
|
|
808
|
+
} else if (ch === "}") {
|
|
809
|
+
depth -= 1;
|
|
810
|
+
if (depth === 0) {
|
|
811
|
+
end = i + 1;
|
|
812
|
+
break;
|
|
813
|
+
}
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
if (end === -1) {
|
|
817
|
+
return void 0;
|
|
818
|
+
}
|
|
819
|
+
cursor = cursor.slice(end);
|
|
820
|
+
}
|
|
821
|
+
const trailing = cursor.trimStart();
|
|
822
|
+
if (trailing === "" || trailing[0] !== "}") {
|
|
823
|
+
return void 0;
|
|
824
|
+
}
|
|
825
|
+
return fieldName;
|
|
826
|
+
};
|
|
827
|
+
const extractQueryRootField = (data) => {
|
|
828
|
+
if (typeof data !== "object" || data === null || Array.isArray(data)) {
|
|
829
|
+
return void 0;
|
|
830
|
+
}
|
|
831
|
+
const { query } = data;
|
|
832
|
+
if (typeof query !== "string") {
|
|
833
|
+
return void 0;
|
|
834
|
+
}
|
|
835
|
+
return extractRootField(stripLeadingComments$1(query));
|
|
836
|
+
};
|
|
837
|
+
const MUTATION_ROOT_FIELD_INVALIDATION = /* @__PURE__ */ new Map([
|
|
838
|
+
// Media list entry writes change what MediaList, MediaListCollection,
|
|
839
|
+
// and Page (media lists) queries report, plus the Media documents
|
|
840
|
+
// that embed the requesting user's list entry for that media
|
|
841
|
+
// (`Media.mediaListEntry`), the User/Viewer documents that embed a
|
|
842
|
+
// `mediaListCollection`, and the Activity queries that embed the
|
|
843
|
+
// ListActivity AniList generates from the write. The shipped schemas
|
|
844
|
+
// do not select those embeds, but `custom()` documents can, and the
|
|
845
|
+
// map must not under-invalidate them.
|
|
846
|
+
[
|
|
847
|
+
"SaveMediaListEntry",
|
|
848
|
+
["MediaList", "MediaListCollection", "Page", "Media", "User", "Viewer", "Activity"]
|
|
849
|
+
],
|
|
850
|
+
[
|
|
851
|
+
"UpdateMediaListEntries",
|
|
852
|
+
["MediaList", "MediaListCollection", "Page", "Media", "User", "Viewer", "Activity"]
|
|
853
|
+
],
|
|
854
|
+
[
|
|
855
|
+
"DeleteMediaListEntry",
|
|
856
|
+
["MediaList", "MediaListCollection", "Page", "Media", "User", "Viewer", "Activity"]
|
|
857
|
+
],
|
|
858
|
+
// Favourite toggles change the user's Favourites, the User/Viewer
|
|
859
|
+
// documents that embed them, the isFavourite/favourites embeds on the
|
|
860
|
+
// object types the shipped Media/Character/Staff/Studio schemas
|
|
861
|
+
// select, and the Page queries that reach those embeds
|
|
862
|
+
// (Page→users→favourites, Page→media→isFavourite).
|
|
863
|
+
[
|
|
864
|
+
"ToggleFavourite",
|
|
865
|
+
["Favourites", "User", "Viewer", "Media", "Character", "Staff", "Studio", "Page"]
|
|
866
|
+
],
|
|
867
|
+
[
|
|
868
|
+
"UpdateFavouriteOrder",
|
|
869
|
+
["Favourites", "User", "Viewer", "Media", "Character", "Staff", "Studio", "Page"]
|
|
870
|
+
],
|
|
871
|
+
// Review writes change Review queries, Page (reviews), the Media
|
|
872
|
+
// documents that embed the reviews connection (`Media.reviews`), and
|
|
873
|
+
// the User/Viewer documents that embed one (`User.reviews`).
|
|
874
|
+
["SaveReview", ["Review", "Page", "Media", "User", "Viewer"]],
|
|
875
|
+
["DeleteReview", ["Review", "Page", "Media", "User", "Viewer"]],
|
|
876
|
+
["RateReview", ["Review", "Page", "Media", "User", "Viewer"]],
|
|
877
|
+
// Profile writes change User and Viewer documents, the Follower and
|
|
878
|
+
// Following collections (the user's name/avatar/about change what they
|
|
879
|
+
// report), and the Page queries that embed user profiles. The
|
|
880
|
+
// animeListOptions/mangaListOptions variables also change list options
|
|
881
|
+
// (custom lists, scoring display), so the MediaList/MediaListCollection
|
|
882
|
+
// queries and the Media documents embedding the user's list entry
|
|
883
|
+
// (`Media.mediaListEntry`) are affected too.
|
|
884
|
+
[
|
|
885
|
+
"UpdateUser",
|
|
886
|
+
[
|
|
887
|
+
"User",
|
|
888
|
+
"Viewer",
|
|
889
|
+
"Follower",
|
|
890
|
+
"Following",
|
|
891
|
+
"Page",
|
|
892
|
+
"MediaList",
|
|
893
|
+
"MediaListCollection",
|
|
894
|
+
"Media"
|
|
895
|
+
]
|
|
896
|
+
]
|
|
897
|
+
]);
|
|
595
898
|
const MUTATION_ACTION_SEGMENTS = /* @__PURE__ */ new Set(["my_list_status"]);
|
|
596
899
|
const deriveMutationCachePrefix = (url) => {
|
|
597
900
|
const fragmentIndex = url.indexOf("#");
|
|
@@ -618,7 +921,13 @@ const invalidateAfterMutation = (cache, method, url, data, isRestCall) => {
|
|
|
618
921
|
return;
|
|
619
922
|
}
|
|
620
923
|
if (!isRestCall && isGraphQLDocumentRequest(method, data) && !isCacheableRequest(method, data)) {
|
|
621
|
-
|
|
924
|
+
const rootField = extractQueryRootField(data);
|
|
925
|
+
const affected = rootField === void 0 ? void 0 : MUTATION_ROOT_FIELD_INVALIDATION.get(rootField);
|
|
926
|
+
if (affected !== void 0) {
|
|
927
|
+
cache.deleteRootFieldsForUrl(url, affected);
|
|
928
|
+
} else {
|
|
929
|
+
cache.deleteAllForUrl(url);
|
|
930
|
+
}
|
|
622
931
|
return;
|
|
623
932
|
}
|
|
624
933
|
const prefix = deriveMutationCachePrefix(url);
|
|
@@ -626,6 +935,29 @@ const invalidateAfterMutation = (cache, method, url, data, isRestCall) => {
|
|
|
626
935
|
cache.deleteMatching(prefix);
|
|
627
936
|
}
|
|
628
937
|
};
|
|
938
|
+
const tryCacheRead = (resolved, method, url, data, cacheAuthKey) => {
|
|
939
|
+
const cached = resolved.responseCache.get(method, url, data, cacheAuthKey);
|
|
940
|
+
if (cached === void 0) {
|
|
941
|
+
return void 0;
|
|
942
|
+
}
|
|
943
|
+
if (resolved.onRequestStart !== void 0 || resolved.onResponse !== void 0) {
|
|
944
|
+
const requestId = randomUUID();
|
|
945
|
+
const hookContext = { requestId, url, method, attempt: 1 };
|
|
946
|
+
safeInvoke(
|
|
947
|
+
resolved.onRequestStart,
|
|
948
|
+
"onRequestStart",
|
|
949
|
+
resolved.onHookError,
|
|
950
|
+
resolved.diagnostics,
|
|
951
|
+
hookContext
|
|
952
|
+
);
|
|
953
|
+
safeInvoke(resolved.onResponse, "onResponse", resolved.onHookError, resolved.diagnostics, {
|
|
954
|
+
...hookContext,
|
|
955
|
+
durationMs: 0,
|
|
956
|
+
cacheHit: true
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
return cached;
|
|
960
|
+
};
|
|
629
961
|
const MAX_INVALIDATION_EVENTS = 64;
|
|
630
962
|
const isPlainObject$1 = (value) => {
|
|
631
963
|
const proto = Object.getPrototypeOf(value);
|
|
@@ -699,12 +1031,12 @@ class ResponseCache {
|
|
|
699
1031
|
}
|
|
700
1032
|
/**
|
|
701
1033
|
* Canonicalizes a URL for keying: the query string's parameters are
|
|
702
|
-
* sorted so `?a=1&b=2` and `?b=2&a=1
|
|
1034
|
+
* sorted so `?a=1&b=2` and `?b=2&a=1`, the same resource, share one
|
|
703
1035
|
* cache entry instead of missing each other.
|
|
704
1036
|
*
|
|
705
1037
|
* The query string is anchored at the first `?` that appears before any
|
|
706
1038
|
* `#`, so a `?` inside a fragment (`path#frag?x`) is never mistaken for
|
|
707
|
-
* the query delimiter
|
|
1039
|
+
* the query delimiter. That is harmless for keying, but the method must stay
|
|
708
1040
|
* correct if it is ever reused for matching or allowlists.
|
|
709
1041
|
*
|
|
710
1042
|
* @param url - The request URL, possibly carrying a query string.
|
|
@@ -731,7 +1063,7 @@ class ResponseCache {
|
|
|
731
1063
|
*
|
|
732
1064
|
* The serialized body is SHA-256 hashed (truncated to 16 hex chars)
|
|
733
1065
|
* before it enters the key, so a credential-bearing GET body is never
|
|
734
|
-
* duplicated into the key string in plaintext
|
|
1066
|
+
* duplicated into the key string in plaintext. The key map retains
|
|
735
1067
|
* entries for up to the TTL, outliving the error paths the rest of the
|
|
736
1068
|
* library scrubs. The hash is deterministic, so equal bodies still share
|
|
737
1069
|
* one entry and different bodies still get different entries. The URL's
|
|
@@ -815,15 +1147,15 @@ class ResponseCache {
|
|
|
815
1147
|
}
|
|
816
1148
|
/**
|
|
817
1149
|
* Stores a response in the cache, evicting the LRU entry when the cap is
|
|
818
|
-
* is reached. Only cacheable reads are stored
|
|
1150
|
+
* is reached. Only cacheable reads are stored, `GET` requests and
|
|
819
1151
|
* GraphQL query documents dispatched as `POST`; mutations and other
|
|
820
1152
|
* methods are no-ops. The value is deep-copied on write; the cache never
|
|
821
1153
|
* aliases the caller's object.
|
|
822
1154
|
*
|
|
823
1155
|
* Note for direct callers: a `POST` body shaped like `{ query: "..." }`
|
|
824
1156
|
* is treated as a GraphQL document and cached when the document declares
|
|
825
|
-
* a read. Do not use `set` for REST `POST` writes whose body
|
|
826
|
-
* carries a `query` field
|
|
1157
|
+
* a read. Do not use `set` for REST `POST` writes whose body only
|
|
1158
|
+
* carries a `query` field: the transport excludes those via its protocol
|
|
827
1159
|
* flag, but `set` itself cannot distinguish them.
|
|
828
1160
|
*
|
|
829
1161
|
* @param method - The HTTP method.
|
|
@@ -834,14 +1166,35 @@ class ResponseCache {
|
|
|
834
1166
|
* @param response - The response body to cache.
|
|
835
1167
|
*/
|
|
836
1168
|
set(method, url, data, authKey, response) {
|
|
837
|
-
|
|
838
|
-
|
|
1169
|
+
this.setEntry(method, url, data, authKey, response, void 0);
|
|
1170
|
+
}
|
|
1171
|
+
/**
|
|
1172
|
+
* The single write path behind {@link set} and {@link setIfFresh}: all
|
|
1173
|
+
* the write-side guards (cacheability, `ttlMs: 0`, the clone, the
|
|
1174
|
+
* opportunistic purge, LRU eviction) with the entry's root field
|
|
1175
|
+
* recorded for GraphQL query documents so scoped invalidation can
|
|
1176
|
+
* attribute the entry to the root field its document selects.
|
|
1177
|
+
*
|
|
1178
|
+
* @param method - The HTTP method.
|
|
1179
|
+
* @param url - The request URL.
|
|
1180
|
+
* @param data - The request body, when present.
|
|
1181
|
+
* @param authKey - An authentication-safe credential identity.
|
|
1182
|
+
* @param response - The response body to cache.
|
|
1183
|
+
* @param rootField - The GraphQL document's single selected root field,
|
|
1184
|
+
* when the caller knows it (the transport extracts it for query reads).
|
|
1185
|
+
* @returns Whether the response was stored: `false` when a
|
|
1186
|
+
* write-side guard skipped it (not cacheable, `ttlMs: 0`, or an
|
|
1187
|
+
* uncloneable payload).
|
|
1188
|
+
*/
|
|
1189
|
+
setEntry(method, url, data, authKey, response, rootField) {
|
|
1190
|
+
if (!isCacheableRequest(method, data)) return false;
|
|
1191
|
+
if (this.ttlMs === 0) return false;
|
|
839
1192
|
const key = ResponseCache.buildKey(method, url, data, authKey);
|
|
840
1193
|
let snapshot;
|
|
841
1194
|
try {
|
|
842
1195
|
snapshot = structuredClone(response);
|
|
843
1196
|
} catch {
|
|
844
|
-
return;
|
|
1197
|
+
return false;
|
|
845
1198
|
}
|
|
846
1199
|
this.purgeExpired();
|
|
847
1200
|
if (this.entries.has(key)) {
|
|
@@ -851,8 +1204,10 @@ class ResponseCache {
|
|
|
851
1204
|
}
|
|
852
1205
|
this.entries.set(key, {
|
|
853
1206
|
data: snapshot,
|
|
854
|
-
expiresAt: Date.now() + this.ttlMs
|
|
1207
|
+
expiresAt: Date.now() + this.ttlMs,
|
|
1208
|
+
...rootField !== void 0 ? { rootField } : {}
|
|
855
1209
|
});
|
|
1210
|
+
return true;
|
|
856
1211
|
}
|
|
857
1212
|
/**
|
|
858
1213
|
* Returns the current invalidation generation, for callers that need to
|
|
@@ -868,7 +1223,7 @@ class ResponseCache {
|
|
|
868
1223
|
}
|
|
869
1224
|
/**
|
|
870
1225
|
* Stores a response only when no invalidation affecting the request has
|
|
871
|
-
* landed since the caller captured the generation
|
|
1226
|
+
* landed since the caller captured the generation, the write-back half
|
|
872
1227
|
* of the in-flight-read guard.
|
|
873
1228
|
*
|
|
874
1229
|
* The transport captures the generation right after a cache miss (before
|
|
@@ -888,16 +1243,26 @@ class ResponseCache {
|
|
|
888
1243
|
* @param generationAtRead - The generation the caller captured before
|
|
889
1244
|
* the read went to the network.
|
|
890
1245
|
* @param response - The response body to cache.
|
|
1246
|
+
* @param rootField - The GraphQL document's single selected root field,
|
|
1247
|
+
* when the caller knows it, so scoped invalidations that landed while
|
|
1248
|
+
* the read was in flight are matched against the read's own root field;
|
|
1249
|
+
* an unattributed document (`undefined`) is treated as affected by
|
|
1250
|
+
* every scoped invalidation, fail-closed.
|
|
1251
|
+
* @returns Whether the response was stored: `false` when an
|
|
1252
|
+
* invalidation affecting the read landed while it was in flight (the
|
|
1253
|
+
* stale response is dropped) or a write-side guard skipped it. That is
|
|
1254
|
+
* the signal behind the `cacheWrite` flag on the transport's `onResponse`
|
|
1255
|
+
* emission, so consumers can measure cache fill rate.
|
|
891
1256
|
*/
|
|
892
|
-
setIfFresh(method, url, data, authKey, generationAtRead, response) {
|
|
893
|
-
if (this.wasInvalidatedSince(generationAtRead, method, url, data, authKey)) {
|
|
894
|
-
return;
|
|
1257
|
+
setIfFresh(method, url, data, authKey, generationAtRead, response, rootField) {
|
|
1258
|
+
if (this.wasInvalidatedSince(generationAtRead, method, url, data, authKey, rootField)) {
|
|
1259
|
+
return false;
|
|
895
1260
|
}
|
|
896
|
-
this.
|
|
1261
|
+
return this.setEntry(method, url, data, authKey, response, rootField);
|
|
897
1262
|
}
|
|
898
1263
|
/**
|
|
899
1264
|
* Whether an invalidation affecting the given request's key landed
|
|
900
|
-
* after the caller captured the generation
|
|
1265
|
+
* after the caller captured the generation, the decision half of the
|
|
901
1266
|
* in-flight-read guard.
|
|
902
1267
|
*
|
|
903
1268
|
* The check is scoped, not global: an invalidation of one resource does
|
|
@@ -914,9 +1279,13 @@ class ResponseCache {
|
|
|
914
1279
|
* @param url - The URL of the read.
|
|
915
1280
|
* @param data - The request body of the read, when present.
|
|
916
1281
|
* @param authKey - The auth-scoping cache key fragment of the read.
|
|
1282
|
+
* @param rootField - The read's GraphQL root field, when known, so a
|
|
1283
|
+
* scoped invalidation is matched against the read's own root field; an
|
|
1284
|
+
* unattributed document is treated as affected by every scoped
|
|
1285
|
+
* invalidation, fail-closed.
|
|
917
1286
|
* @returns `true` when an affecting invalidation landed mid-flight.
|
|
918
1287
|
*/
|
|
919
|
-
wasInvalidatedSince(generationAtRead, method, url, data, authKey) {
|
|
1288
|
+
wasInvalidatedSince(generationAtRead, method, url, data, authKey, rootField) {
|
|
920
1289
|
if (generationAtRead === this.generation) {
|
|
921
1290
|
return false;
|
|
922
1291
|
}
|
|
@@ -929,7 +1298,7 @@ class ResponseCache {
|
|
|
929
1298
|
if (event.at <= generationAtRead) {
|
|
930
1299
|
break;
|
|
931
1300
|
}
|
|
932
|
-
if (event.affects(key)) {
|
|
1301
|
+
if (event.affects(key, rootField)) {
|
|
933
1302
|
return true;
|
|
934
1303
|
}
|
|
935
1304
|
}
|
|
@@ -940,8 +1309,8 @@ class ResponseCache {
|
|
|
940
1309
|
* so an in-flight read can later tell whether the invalidation affected
|
|
941
1310
|
* its key (see {@link setIfFresh}).
|
|
942
1311
|
*
|
|
943
|
-
* @param affects - Whether a cache key
|
|
944
|
-
* scope.
|
|
1312
|
+
* @param affects - Whether a cache key (with its entry's recorded root
|
|
1313
|
+
* field, when one exists) falls inside the invalidation's scope.
|
|
945
1314
|
*/
|
|
946
1315
|
recordInvalidation(affects) {
|
|
947
1316
|
this.generation += 1;
|
|
@@ -955,8 +1324,8 @@ class ResponseCache {
|
|
|
955
1324
|
* Removes the cached entry for the given request, if present. Use this
|
|
956
1325
|
* for targeted invalidation after a mutation that changes the resource
|
|
957
1326
|
* (for example a `POST` that updates the entity a cached `GET` returned).
|
|
958
|
-
* Only cacheable reads are tracked
|
|
959
|
-
* documents dispatched as `POST
|
|
1327
|
+
* Only cacheable reads are tracked, `GET` requests and GraphQL query
|
|
1328
|
+
* documents dispatched as `POST`, so other methods are a no-op and
|
|
960
1329
|
* return `false`.
|
|
961
1330
|
*
|
|
962
1331
|
* @param method - The HTTP method.
|
|
@@ -981,14 +1350,14 @@ class ResponseCache {
|
|
|
981
1350
|
* string and fragment stripped, so a cached read of
|
|
982
1351
|
* `https://host/anime/21?fields=...` is invalidated by the prefix
|
|
983
1352
|
* `https://host/anime/21`. The match is boundary-aware: the prefix
|
|
984
|
-
* `https://host/anime/21` does **not** match `https://host/anime/212
|
|
985
|
-
*
|
|
1353
|
+
* `https://host/anime/21` does **not** match `https://host/anime/212`.
|
|
1354
|
+
* The cached URL must be either exactly the prefix, continue with `/`
|
|
986
1355
|
* (a child path), `?` (a query string), or `#` (a fragment). Matching
|
|
987
1356
|
* spans every auth namespace, because a mutation performed by one
|
|
988
1357
|
* identity changes the underlying resource for every identity that can
|
|
989
1358
|
* read it.
|
|
990
1359
|
*
|
|
991
|
-
* This is the invalidation
|
|
1360
|
+
* This is the invalidation path used by mutation-triggered cache
|
|
992
1361
|
* invalidation for REST writes: after a successful write, the transport
|
|
993
1362
|
* derives the mutated resource's base URL and drops every cached read
|
|
994
1363
|
* of that resource. It is also usable directly for manual bulk
|
|
@@ -1018,18 +1387,18 @@ class ResponseCache {
|
|
|
1018
1387
|
return removed;
|
|
1019
1388
|
}
|
|
1020
1389
|
/**
|
|
1021
|
-
* Removes every cached read keyed at the given URL
|
|
1390
|
+
* Removes every cached read keyed at the given URL, `GET` entries and
|
|
1022
1391
|
* GraphQL query `POST` entries alike, across query strings, documents,
|
|
1023
|
-
* variables, and auth namespaces
|
|
1392
|
+
* variables, and auth namespaces, and returns how many entries were
|
|
1024
1393
|
* removed.
|
|
1025
1394
|
*
|
|
1026
|
-
* This is the invalidation
|
|
1395
|
+
* This is the invalidation path for GraphQL writes: every GraphQL
|
|
1027
1396
|
* operation of one provider is keyed at the same endpoint URL, and a
|
|
1028
1397
|
* `mutation` document can change what many different query documents
|
|
1029
1398
|
* return (a `SaveMediaListEntry` changes what both a `MediaList` and a
|
|
1030
1399
|
* `MediaListCollection` query report), so no finer-grained prefix than
|
|
1031
1400
|
* the endpoint can be derived. Dropping the endpoint's cached queries is
|
|
1032
|
-
*
|
|
1401
|
+
* conservative: the next reads refetch fresh data instead
|
|
1033
1402
|
* of serving pre-mutation entries for the rest of the TTL.
|
|
1034
1403
|
*
|
|
1035
1404
|
* @param url - The request URL whose cached reads should be dropped;
|
|
@@ -1060,6 +1429,50 @@ class ResponseCache {
|
|
|
1060
1429
|
}
|
|
1061
1430
|
return removed;
|
|
1062
1431
|
}
|
|
1432
|
+
/**
|
|
1433
|
+
* Removes every cached GraphQL query `POST` entry at the given URL whose
|
|
1434
|
+
* document selects one of the given root fields, and returns how many
|
|
1435
|
+
* entries were removed.
|
|
1436
|
+
*
|
|
1437
|
+
* This is the scoped invalidation path for mapped GraphQL mutations
|
|
1438
|
+
* (see {@link invalidateAfterMutation}): a mutation whose root field is
|
|
1439
|
+
* known to affect only certain query root fields drops exactly those
|
|
1440
|
+
* cached queries, leaving unrelated root fields' entries warm, instead
|
|
1441
|
+
* of the whole-endpoint sweep {@link deleteAllForUrl} performs. The
|
|
1442
|
+
* match is against the document's single selected root field, recorded
|
|
1443
|
+
* on the entry at write time (see {@link CacheEntry.rootField}), so a
|
|
1444
|
+
* query document is attributed to the root field it selects,
|
|
1445
|
+
* not to every field it mentions. Entries whose document could not be
|
|
1446
|
+
* attributed to a single root field (fragment-spread roots,
|
|
1447
|
+
* multi-field selections) are dropped too, fail-closed like the
|
|
1448
|
+
* unmapped-mutation fallback, because such a document may select data
|
|
1449
|
+
* the affected-field match cannot see.
|
|
1450
|
+
*
|
|
1451
|
+
* @param url - The request URL whose cached reads should be considered;
|
|
1452
|
+
* its query string and fragment are ignored.
|
|
1453
|
+
* @param rootFields - The query root fields whose cached entries should
|
|
1454
|
+
* be dropped.
|
|
1455
|
+
* @returns The number of cached entries removed.
|
|
1456
|
+
*/
|
|
1457
|
+
deleteRootFieldsForUrl(url, rootFields) {
|
|
1458
|
+
const fragmentIndex = url.indexOf("#");
|
|
1459
|
+
const withoutFragment = fragmentIndex === -1 ? url : url.slice(0, fragmentIndex);
|
|
1460
|
+
const queryIndex = withoutFragment.indexOf("?");
|
|
1461
|
+
const base = queryIndex === -1 ? withoutFragment : withoutFragment.slice(0, queryIndex);
|
|
1462
|
+
const prefix = base.endsWith("/") && base.length > 1 ? base.slice(0, -1) : base;
|
|
1463
|
+
const fieldSet = new Set(rootFields);
|
|
1464
|
+
this.recordInvalidation(
|
|
1465
|
+
(key, rootField) => ResponseCache.keyAtPrefix(key, `POST:${prefix}`, ":?#") && (rootField === void 0 || fieldSet.has(rootField))
|
|
1466
|
+
);
|
|
1467
|
+
let removed = 0;
|
|
1468
|
+
for (const [key, entry] of this.entries) {
|
|
1469
|
+
if (ResponseCache.keyAtPrefix(key, `POST:${prefix}`, ":?#") && (entry.rootField === void 0 || fieldSet.has(entry.rootField))) {
|
|
1470
|
+
this.entries.delete(key);
|
|
1471
|
+
removed += 1;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return removed;
|
|
1475
|
+
}
|
|
1063
1476
|
/**
|
|
1064
1477
|
* Evicts the least-recently-used entry.
|
|
1065
1478
|
*/
|
|
@@ -1105,13 +1518,14 @@ class ResponseCache {
|
|
|
1105
1518
|
* counters, so cache tuning (`ttlMs`/`maxEntries`) can be data-driven:
|
|
1106
1519
|
* entry counts and eviction/expiration counters distinguish a too-small
|
|
1107
1520
|
* cache (rising `evictions`) from a too-short TTL (rising
|
|
1108
|
-
* `expirations`), and hit-rate dashboards
|
|
1109
|
-
*
|
|
1110
|
-
*
|
|
1521
|
+
* `expirations`), and hit-rate dashboards compute
|
|
1522
|
+
* `hits / (hits + misses + expirations)`: an expired-on-read entry
|
|
1523
|
+
* counts as an expiration, not a miss, so the three counters partition
|
|
1524
|
+
* every `get()` call.
|
|
1111
1525
|
*
|
|
1112
|
-
* The counters are cumulative for the cache instance's lifetime
|
|
1526
|
+
* The counters are cumulative for the cache instance's lifetime:
|
|
1113
1527
|
* `delete()`, `deleteMatching()`, `deleteAllForUrl()`, and `clear()`
|
|
1114
|
-
* drop entries but never reset or increment the counters
|
|
1528
|
+
* drop entries but never reset or increment the counters, and
|
|
1115
1529
|
* `entries` reflects the current live entry count. The returned object
|
|
1116
1530
|
* is frozen, so a caller cannot mutate the cache's internal state
|
|
1117
1531
|
* through it.
|
|
@@ -1313,88 +1727,6 @@ const unwrapGraphQLResponse = (response, headers, options) => {
|
|
|
1313
1727
|
return unwrapped === void 0 ? response : unwrapped;
|
|
1314
1728
|
};
|
|
1315
1729
|
|
|
1316
|
-
const buildDiagnostic = (kind, hookName, message, requestId) => ({
|
|
1317
|
-
source: "anilink",
|
|
1318
|
-
kind,
|
|
1319
|
-
hookName,
|
|
1320
|
-
...requestId === void 0 ? {} : { requestId },
|
|
1321
|
-
message
|
|
1322
|
-
});
|
|
1323
|
-
const reportDiagnostic = (options) => {
|
|
1324
|
-
const { kind, hookName, message, requestId, onHookError, diagnostics, rawError, rethrown } = options;
|
|
1325
|
-
if (onHookError !== void 0 && (rawError !== void 0 || diagnostics !== "silent")) {
|
|
1326
|
-
const record = buildDiagnostic(kind, hookName, message, requestId);
|
|
1327
|
-
try {
|
|
1328
|
-
onHookError(
|
|
1329
|
-
hookName,
|
|
1330
|
-
new Error(message, {
|
|
1331
|
-
cause: rawError !== void 0 ? rawError : record
|
|
1332
|
-
})
|
|
1333
|
-
);
|
|
1334
|
-
} catch {
|
|
1335
|
-
}
|
|
1336
|
-
return true;
|
|
1337
|
-
}
|
|
1338
|
-
if (rethrown === true || diagnostics !== "warn") {
|
|
1339
|
-
return false;
|
|
1340
|
-
}
|
|
1341
|
-
console.warn(JSON.stringify(buildDiagnostic(kind, hookName, message, requestId)));
|
|
1342
|
-
return true;
|
|
1343
|
-
};
|
|
1344
|
-
const safeInvoke = (hook, name, onHookError, diagnostics, ...args) => {
|
|
1345
|
-
if (hook === void 0) {
|
|
1346
|
-
return;
|
|
1347
|
-
}
|
|
1348
|
-
try {
|
|
1349
|
-
hook(...args);
|
|
1350
|
-
} catch (hookError) {
|
|
1351
|
-
const firstArg = args[0];
|
|
1352
|
-
const requestId = firstArg !== null && typeof firstArg === "object" && "requestId" in firstArg && typeof firstArg.requestId === "string" ? firstArg.requestId : void 0;
|
|
1353
|
-
const detail = hookError instanceof Error ? hookError.message : String(hookError);
|
|
1354
|
-
reportDiagnostic({
|
|
1355
|
-
kind: "hook-failure",
|
|
1356
|
-
hookName: name,
|
|
1357
|
-
message: `The ${name} hook threw and was ignored: ${detail}`,
|
|
1358
|
-
requestId,
|
|
1359
|
-
onHookError,
|
|
1360
|
-
diagnostics,
|
|
1361
|
-
rawError: hookError
|
|
1362
|
-
});
|
|
1363
|
-
}
|
|
1364
|
-
};
|
|
1365
|
-
const buildErrorContext = (requestId, url, method, attempt, normalized, nextDelayMs) => ({
|
|
1366
|
-
requestId,
|
|
1367
|
-
url,
|
|
1368
|
-
method,
|
|
1369
|
-
attempt,
|
|
1370
|
-
code: normalized.code,
|
|
1371
|
-
...normalized instanceof AniLinkApiError ? { status: normalized.status } : {},
|
|
1372
|
-
...normalized instanceof AniLinkApiError && normalized.rateLimit !== void 0 ? { rateLimit: normalized.rateLimit } : {},
|
|
1373
|
-
...nextDelayMs === void 0 ? {} : { nextDelayMs }
|
|
1374
|
-
});
|
|
1375
|
-
const reportFailure = (requestId, url, method, attempt, normalized, resolved, nextDelayMs) => {
|
|
1376
|
-
const context = buildErrorContext(requestId, url, method, attempt, normalized, nextDelayMs);
|
|
1377
|
-
if (nextDelayMs !== void 0) {
|
|
1378
|
-
safeInvoke(
|
|
1379
|
-
resolved.onRetry ?? resolved.onError,
|
|
1380
|
-
resolved.onRetry === void 0 ? "onError" : "onRetry",
|
|
1381
|
-
resolved.onHookError,
|
|
1382
|
-
resolved.diagnostics,
|
|
1383
|
-
normalized,
|
|
1384
|
-
context
|
|
1385
|
-
);
|
|
1386
|
-
return;
|
|
1387
|
-
}
|
|
1388
|
-
safeInvoke(
|
|
1389
|
-
resolved.onError,
|
|
1390
|
-
"onError",
|
|
1391
|
-
resolved.onHookError,
|
|
1392
|
-
resolved.diagnostics,
|
|
1393
|
-
normalized,
|
|
1394
|
-
context
|
|
1395
|
-
);
|
|
1396
|
-
};
|
|
1397
|
-
|
|
1398
1730
|
const circuitStates = /* @__PURE__ */ new WeakMap();
|
|
1399
1731
|
const MAX_PROBE_BACKOFF_EXPONENT = 3;
|
|
1400
1732
|
const scaledCooldownMs = (breaker, failedProbes) => {
|
|
@@ -1410,6 +1742,7 @@ const isAvailabilityFailure = (error) => {
|
|
|
1410
1742
|
}
|
|
1411
1743
|
return false;
|
|
1412
1744
|
};
|
|
1745
|
+
const isStreakNeutralFailure = (error) => error instanceof AniLinkGraphQLError && error.status === 200;
|
|
1413
1746
|
const MAX_CIRCUIT_SCOPES_PER_OWNER = 64;
|
|
1414
1747
|
const circuitScopeOf = (url, requestId) => {
|
|
1415
1748
|
try {
|
|
@@ -1447,6 +1780,16 @@ const getCircuitState = (owner, scope) => {
|
|
|
1447
1780
|
scopes.set(scope, state);
|
|
1448
1781
|
return state;
|
|
1449
1782
|
};
|
|
1783
|
+
const circuitCooldownRemainingMs = (circuit, breaker) => {
|
|
1784
|
+
if (circuit === void 0 || breaker === void 0) {
|
|
1785
|
+
return void 0;
|
|
1786
|
+
}
|
|
1787
|
+
if (circuit.probeInFlight || circuit.openedAt === null) {
|
|
1788
|
+
return void 0;
|
|
1789
|
+
}
|
|
1790
|
+
const remaining = circuit.openedAt + scaledCooldownMs(breaker, circuit.failedProbes) - Date.now();
|
|
1791
|
+
return remaining > 0 ? remaining : void 0;
|
|
1792
|
+
};
|
|
1450
1793
|
const peekCircuitStates = (owner) => circuitStates.get(owner);
|
|
1451
1794
|
const checkCircuitOpen = (circuit, breaker) => {
|
|
1452
1795
|
if (circuit === void 0 || breaker === void 0) {
|
|
@@ -1498,6 +1841,9 @@ const recordCircuitFailure = (circuit, breaker, normalized, resolved, hookContex
|
|
|
1498
1841
|
return;
|
|
1499
1842
|
}
|
|
1500
1843
|
if (!isAvailabilityFailure(normalized)) {
|
|
1844
|
+
if (isStreakNeutralFailure(normalized) && !circuit.probeInFlight) {
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1501
1847
|
recordCircuitSuccess(circuit, resolved, hookContext, host);
|
|
1502
1848
|
return;
|
|
1503
1849
|
}
|
|
@@ -1550,6 +1896,8 @@ const sleep = (ms, signal, requestId) => new Promise((resolve, reject) => {
|
|
|
1550
1896
|
|
|
1551
1897
|
const paceDeadlines = /* @__PURE__ */ new WeakMap();
|
|
1552
1898
|
const MAX_PACE_WAIT_MS = 5 * 60 * 1e3;
|
|
1899
|
+
const MAX_PACE_SCOPES_PER_OWNER = 64;
|
|
1900
|
+
const MAX_PACE_JITTER_MS = 500;
|
|
1553
1901
|
const sleepForPacing = async (delayMs, resolved, hookContext) => {
|
|
1554
1902
|
try {
|
|
1555
1903
|
await sleep(delayMs, resolved.signal, hookContext.requestId);
|
|
@@ -1573,79 +1921,121 @@ const recordPaceDeadline = (owner, host, deadlineMs) => {
|
|
|
1573
1921
|
scopes = /* @__PURE__ */ new Map();
|
|
1574
1922
|
paceDeadlines.set(owner, scopes);
|
|
1575
1923
|
}
|
|
1576
|
-
|
|
1577
|
-
|
|
1924
|
+
const now = Date.now();
|
|
1925
|
+
const existing = scopes.get(host);
|
|
1926
|
+
if (deadlineMs <= now) {
|
|
1927
|
+
if (existing === void 0 || existing <= now) {
|
|
1928
|
+
scopes.delete(host);
|
|
1929
|
+
}
|
|
1578
1930
|
return;
|
|
1579
1931
|
}
|
|
1580
|
-
|
|
1581
|
-
|
|
1932
|
+
if (existing !== void 0) {
|
|
1933
|
+
scopes.delete(host);
|
|
1934
|
+
scopes.set(host, Math.max(existing, deadlineMs));
|
|
1582
1935
|
return;
|
|
1583
1936
|
}
|
|
1937
|
+
if (scopes.size >= MAX_PACE_SCOPES_PER_OWNER) {
|
|
1938
|
+
const oldestHost = scopes.keys().next().value;
|
|
1939
|
+
if (oldestHost !== void 0) {
|
|
1940
|
+
scopes.delete(oldestHost);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1584
1943
|
scopes.set(host, deadlineMs);
|
|
1585
1944
|
};
|
|
1586
1945
|
const awaitPaceDeadline = async (owner, host, resolved, hookContext) => {
|
|
1587
1946
|
if (owner === void 0 || !resolved.paceWithRateLimit || resolved.ignorePaceDeadline) {
|
|
1588
|
-
return;
|
|
1947
|
+
return 0;
|
|
1948
|
+
}
|
|
1949
|
+
const scopes = paceDeadlines.get(owner);
|
|
1950
|
+
if (scopes === void 0) {
|
|
1951
|
+
return 0;
|
|
1589
1952
|
}
|
|
1590
|
-
const deadlineMs =
|
|
1953
|
+
const deadlineMs = scopes.get(host);
|
|
1591
1954
|
if (deadlineMs === void 0) {
|
|
1592
|
-
return;
|
|
1955
|
+
return 0;
|
|
1593
1956
|
}
|
|
1594
1957
|
const delayMs = deadlineMs - Date.now();
|
|
1595
1958
|
if (delayMs <= 0) {
|
|
1596
|
-
|
|
1597
|
-
return;
|
|
1959
|
+
scopes.delete(host);
|
|
1960
|
+
return 0;
|
|
1961
|
+
}
|
|
1962
|
+
scopes.delete(host);
|
|
1963
|
+
scopes.set(host, deadlineMs);
|
|
1964
|
+
const jitterMs = randomInt(Math.min(MAX_PACE_JITTER_MS, Math.floor(delayMs / 10)) + 1);
|
|
1965
|
+
const waitStartedAt = Date.now();
|
|
1966
|
+
try {
|
|
1967
|
+
await sleepForPacing(delayMs + jitterMs, resolved, hookContext);
|
|
1968
|
+
} catch (error) {
|
|
1969
|
+
if (error instanceof AniLinkNetworkError && error.code === AniLinkErrorCodes.ABORTED && error.abortedDuringPacing === true) {
|
|
1970
|
+
const elapsedMs = Math.min(Date.now() - waitStartedAt, delayMs);
|
|
1971
|
+
safeInvoke(resolved.onPace, "onPace", resolved.onHookError, resolved.diagnostics, {
|
|
1972
|
+
...hookContext,
|
|
1973
|
+
delayMs: elapsedMs,
|
|
1974
|
+
aborted: true
|
|
1975
|
+
});
|
|
1976
|
+
throw error;
|
|
1977
|
+
}
|
|
1978
|
+
throw error;
|
|
1598
1979
|
}
|
|
1599
|
-
await sleepForPacing(delayMs, resolved, hookContext);
|
|
1600
1980
|
safeInvoke(resolved.onPace, "onPace", resolved.onHookError, resolved.diagnostics, {
|
|
1601
1981
|
...hookContext,
|
|
1602
1982
|
delayMs
|
|
1603
1983
|
});
|
|
1984
|
+
return delayMs;
|
|
1604
1985
|
};
|
|
1605
1986
|
const paceAfterSuccess = (response, resolved, rateLimit, owner, host) => {
|
|
1606
1987
|
if (!resolved.paceWithRateLimit) {
|
|
1607
1988
|
return;
|
|
1608
1989
|
}
|
|
1609
|
-
const info = rateLimit ?? getRateLimitInfo(response.headers);
|
|
1610
|
-
if (info !== void 0 && info.remaining < resolved.rateLimitFloor) {
|
|
1611
|
-
const rawDeadlineMs = info.reset * 1e3;
|
|
1612
|
-
const deadlineMs = Math.min(rawDeadlineMs, Date.now() + MAX_PACE_WAIT_MS);
|
|
1613
|
-
if (owner !== void 0 && host !== void 0) {
|
|
1614
|
-
recordPaceDeadline(owner, host, deadlineMs);
|
|
1615
|
-
}
|
|
1990
|
+
const info = rateLimit ?? getRateLimitInfo(response.headers);
|
|
1991
|
+
if (info !== void 0 && info.remaining < resolved.rateLimitFloor) {
|
|
1992
|
+
const rawDeadlineMs = info.reset * 1e3;
|
|
1993
|
+
const deadlineMs = Math.min(rawDeadlineMs, Date.now() + MAX_PACE_WAIT_MS);
|
|
1994
|
+
if (owner !== void 0 && host !== void 0) {
|
|
1995
|
+
recordPaceDeadline(owner, host, deadlineMs);
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
};
|
|
1999
|
+
const paceAfterTerminalRateLimit = (normalized, resolved, owner, host) => {
|
|
2000
|
+
if (!resolved.paceWithRateLimit) {
|
|
2001
|
+
return;
|
|
2002
|
+
}
|
|
2003
|
+
if (!(normalized instanceof AniLinkApiError) || normalized.status !== 429) {
|
|
2004
|
+
return;
|
|
2005
|
+
}
|
|
2006
|
+
const reset = normalized.rateLimit?.reset;
|
|
2007
|
+
if (reset === void 0 || !Number.isFinite(reset)) {
|
|
2008
|
+
return;
|
|
2009
|
+
}
|
|
2010
|
+
const deadlineMs = Math.min(reset * 1e3, Date.now() + MAX_PACE_WAIT_MS);
|
|
2011
|
+
if (owner !== void 0) {
|
|
2012
|
+
recordPaceDeadline(owner, host, deadlineMs);
|
|
1616
2013
|
}
|
|
1617
2014
|
};
|
|
1618
2015
|
const peekPaceDeadlines = (owner) => paceDeadlines.get(owner);
|
|
1619
2016
|
|
|
1620
|
-
const resolveEnvelopeOutcome = (response, resolved, rawPassthrough
|
|
2017
|
+
const resolveEnvelopeOutcome = (response, resolved, rawPassthrough) => {
|
|
1621
2018
|
let resolvedPartial = false;
|
|
1622
|
-
let
|
|
2019
|
+
let partialError;
|
|
2020
|
+
let partialBreakerError;
|
|
1623
2021
|
const result = rawPassthrough ? response.data : unwrapGraphQLResponse(
|
|
1624
2022
|
response.data,
|
|
1625
2023
|
response.headers,
|
|
1626
2024
|
resolved.allowPartialData ? {
|
|
1627
2025
|
allowPartialData: true,
|
|
1628
|
-
onPartialData: (
|
|
2026
|
+
onPartialData: (error) => {
|
|
1629
2027
|
resolvedPartial = true;
|
|
1630
|
-
|
|
1631
|
-
|
|
2028
|
+
partialError = error;
|
|
2029
|
+
if (isAvailabilityFailure(error) || isStreakNeutralFailure(error)) {
|
|
2030
|
+
partialBreakerError = error;
|
|
1632
2031
|
}
|
|
1633
|
-
stampRequestId(partialError, requestId);
|
|
1634
|
-
safeInvoke(
|
|
1635
|
-
resolved.onError,
|
|
1636
|
-
"onError",
|
|
1637
|
-
resolved.onHookError,
|
|
1638
|
-
resolved.diagnostics,
|
|
1639
|
-
partialError,
|
|
1640
|
-
buildErrorContext(requestId, url, method, attempt + 1, partialError)
|
|
1641
|
-
);
|
|
1642
2032
|
}
|
|
1643
2033
|
} : void 0
|
|
1644
2034
|
);
|
|
1645
|
-
return { result, resolvedPartial,
|
|
2035
|
+
return { result, resolvedPartial, partialError, partialBreakerError };
|
|
1646
2036
|
};
|
|
1647
2037
|
const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) => {
|
|
1648
|
-
const { rawPassthrough = false, cacheMiss = false } = modifiers;
|
|
2038
|
+
const { rawPassthrough = false, cacheMiss = false, writeBack, authGuard } = modifiers;
|
|
1649
2039
|
const { url, method, data, headers } = options;
|
|
1650
2040
|
const policy = resolved.retry;
|
|
1651
2041
|
const requestId = randomUUID();
|
|
@@ -1654,9 +2044,30 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
|
|
|
1654
2044
|
const budgetState = getRetryBudgetState(stateOwner, resolved.retryBudget);
|
|
1655
2045
|
let attempt = 0;
|
|
1656
2046
|
let pacedWaitMs = 0;
|
|
2047
|
+
let retryWaitMs = 0;
|
|
1657
2048
|
for (; ; ) {
|
|
1658
2049
|
const startedAt = Date.now();
|
|
1659
2050
|
const hookContext = { requestId, url, method, attempt: attempt + 1 };
|
|
2051
|
+
const authError = authGuard?.();
|
|
2052
|
+
if (authError !== void 0) {
|
|
2053
|
+
safeInvoke(
|
|
2054
|
+
resolved.onRequestStart,
|
|
2055
|
+
"onRequestStart",
|
|
2056
|
+
resolved.onHookError,
|
|
2057
|
+
resolved.diagnostics,
|
|
2058
|
+
hookContext
|
|
2059
|
+
);
|
|
2060
|
+
stampRequestId(authError, requestId);
|
|
2061
|
+
safeInvoke(
|
|
2062
|
+
resolved.onError,
|
|
2063
|
+
"onError",
|
|
2064
|
+
resolved.onHookError,
|
|
2065
|
+
resolved.diagnostics,
|
|
2066
|
+
authError,
|
|
2067
|
+
buildErrorContext(requestId, url, method, attempt + 1, authError)
|
|
2068
|
+
);
|
|
2069
|
+
throw authError;
|
|
2070
|
+
}
|
|
1660
2071
|
const circuitError = checkCircuitOpen(circuit, resolved.circuitBreaker);
|
|
1661
2072
|
if (circuitError !== void 0) {
|
|
1662
2073
|
safeInvoke(
|
|
@@ -1667,20 +2078,25 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
|
|
|
1667
2078
|
hookContext
|
|
1668
2079
|
);
|
|
1669
2080
|
stampRequestId(circuitError, requestId);
|
|
2081
|
+
const cooldownRemainingMs = circuitCooldownRemainingMs(
|
|
2082
|
+
circuit,
|
|
2083
|
+
resolved.circuitBreaker
|
|
2084
|
+
);
|
|
1670
2085
|
safeInvoke(
|
|
1671
2086
|
resolved.onError,
|
|
1672
2087
|
"onError",
|
|
1673
2088
|
resolved.onHookError,
|
|
1674
2089
|
resolved.diagnostics,
|
|
1675
2090
|
circuitError,
|
|
1676
|
-
buildErrorContext(requestId, url, method, attempt + 1, circuitError
|
|
2091
|
+
buildErrorContext(requestId, url, method, attempt + 1, circuitError, {
|
|
2092
|
+
host,
|
|
2093
|
+
...cooldownRemainingMs !== void 0 ? { retryAfterMs: cooldownRemainingMs } : {}
|
|
2094
|
+
})
|
|
1677
2095
|
);
|
|
1678
2096
|
throw circuitError;
|
|
1679
2097
|
}
|
|
1680
|
-
const paceStartedAt = Date.now();
|
|
1681
2098
|
try {
|
|
1682
|
-
await awaitPaceDeadline(stateOwner, host, resolved, hookContext);
|
|
1683
|
-
pacedWaitMs += Date.now() - paceStartedAt;
|
|
2099
|
+
pacedWaitMs += await awaitPaceDeadline(stateOwner, host, resolved, hookContext);
|
|
1684
2100
|
} catch (paceError) {
|
|
1685
2101
|
if (circuit !== void 0 && circuit.probeInFlight) {
|
|
1686
2102
|
recordCircuitSuccess(circuit, resolved, hookContext, host);
|
|
@@ -1707,34 +2123,54 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
|
|
|
1707
2123
|
httpsAgent: resolved.httpsAgent
|
|
1708
2124
|
});
|
|
1709
2125
|
const rateLimit = getRateLimitInfo(response.headers);
|
|
2126
|
+
const attemptDurationMs = Date.now() - startedAt;
|
|
2127
|
+
const responseFacts = {
|
|
2128
|
+
...hookContext,
|
|
2129
|
+
durationMs: attemptDurationMs,
|
|
2130
|
+
...rateLimit !== void 0 ? { rateLimit } : {},
|
|
2131
|
+
...cacheMiss ? { cacheHit: false } : {},
|
|
2132
|
+
...pacedWaitMs > 0 ? { pacedMs: pacedWaitMs } : {}
|
|
2133
|
+
};
|
|
2134
|
+
let outcome;
|
|
2135
|
+
try {
|
|
2136
|
+
outcome = resolveEnvelopeOutcome(response, resolved, rawPassthrough);
|
|
2137
|
+
} catch (envelopeError) {
|
|
2138
|
+
safeInvoke(
|
|
2139
|
+
resolved.onResponse,
|
|
2140
|
+
"onResponse",
|
|
2141
|
+
resolved.onHookError,
|
|
2142
|
+
resolved.diagnostics,
|
|
2143
|
+
responseFacts
|
|
2144
|
+
);
|
|
2145
|
+
responseReported = true;
|
|
2146
|
+
throw envelopeError;
|
|
2147
|
+
}
|
|
2148
|
+
const { result, resolvedPartial, partialError, partialBreakerError } = outcome;
|
|
2149
|
+
const cacheWritten = writeBack?.(result, resolvedPartial) ?? false;
|
|
1710
2150
|
safeInvoke(
|
|
1711
2151
|
resolved.onResponse,
|
|
1712
2152
|
"onResponse",
|
|
1713
2153
|
resolved.onHookError,
|
|
1714
2154
|
resolved.diagnostics,
|
|
1715
|
-
{
|
|
1716
|
-
...hookContext,
|
|
1717
|
-
durationMs: Date.now() - startedAt,
|
|
1718
|
-
...rateLimit !== void 0 ? { rateLimit } : {},
|
|
1719
|
-
...cacheMiss ? { cacheHit: false } : {},
|
|
1720
|
-
...pacedWaitMs > 0 ? { pacedMs: pacedWaitMs } : {}
|
|
1721
|
-
}
|
|
2155
|
+
{ ...responseFacts, ...cacheWritten ? { cacheWrite: true } : {} }
|
|
1722
2156
|
);
|
|
1723
2157
|
responseReported = true;
|
|
1724
|
-
|
|
1725
|
-
|
|
1726
|
-
|
|
1727
|
-
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
2158
|
+
if (partialError !== void 0) {
|
|
2159
|
+
stampRequestId(partialError, requestId);
|
|
2160
|
+
safeInvoke(
|
|
2161
|
+
resolved.onError,
|
|
2162
|
+
"onError",
|
|
2163
|
+
resolved.onHookError,
|
|
2164
|
+
resolved.diagnostics,
|
|
2165
|
+
partialError,
|
|
2166
|
+
buildErrorContext(requestId, url, method, attempt + 1, partialError)
|
|
2167
|
+
);
|
|
2168
|
+
}
|
|
2169
|
+
if (partialBreakerError !== void 0) {
|
|
1734
2170
|
recordCircuitFailure(
|
|
1735
2171
|
circuit,
|
|
1736
2172
|
resolved.circuitBreaker,
|
|
1737
|
-
|
|
2173
|
+
partialBreakerError,
|
|
1738
2174
|
resolved,
|
|
1739
2175
|
hookContext,
|
|
1740
2176
|
host
|
|
@@ -1768,7 +2204,7 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
|
|
|
1768
2204
|
hookContext,
|
|
1769
2205
|
host
|
|
1770
2206
|
);
|
|
1771
|
-
const
|
|
2207
|
+
const delayInput = {
|
|
1772
2208
|
normalized,
|
|
1773
2209
|
rawError: error,
|
|
1774
2210
|
attempt,
|
|
@@ -1776,27 +2212,42 @@ const executeWithRetry = async (options, resolved, stateOwner, modifiers = {}) =
|
|
|
1776
2212
|
budgetState,
|
|
1777
2213
|
budget: resolved.retryBudget,
|
|
1778
2214
|
wasProbe
|
|
1779
|
-
}
|
|
2215
|
+
};
|
|
2216
|
+
const delay = computeNextRetryDelay(delayInput);
|
|
1780
2217
|
if (delay !== null && budgetState !== void 0) {
|
|
1781
2218
|
budgetState.retriesUsed += 1;
|
|
1782
2219
|
}
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
2220
|
+
if (delay === null) {
|
|
2221
|
+
paceAfterTerminalRateLimit(normalized, resolved, stateOwner, host);
|
|
2222
|
+
}
|
|
2223
|
+
reportFailure(requestId, url, method, attempt + 1, normalized, resolved, {
|
|
2224
|
+
nextDelayMs: delay ?? void 0,
|
|
2225
|
+
// The cumulative retry wait is present only when a wait
|
|
2226
|
+
// occurred, following the same optional-presence convention
|
|
2227
|
+
// as `pacedMs`. Terminal-failure metrics can report the
|
|
2228
|
+
// total retry investment directly from the onError
|
|
2229
|
+
// payload.
|
|
2230
|
+
...retryWaitMs > 0 ? { retryWaitMs } : {},
|
|
2231
|
+
// The budget-exhaustion signal: this failure was
|
|
2232
|
+
// retryable, but the window's retry spend was already
|
|
2233
|
+
// spent. The hook can distinguish this from a failure that
|
|
2234
|
+
// was never retryable, without polling
|
|
2235
|
+
// transport-state snapshots. Gated on actual retryability
|
|
2236
|
+
// (see isBudgetGatedFailure) so a never-retryable failure
|
|
2237
|
+
// landing in a spent window is not miscounted as chronic
|
|
2238
|
+
// budget exhaustion.
|
|
2239
|
+
...delay === null && isBudgetGatedFailure(delayInput) ? { budgetExhausted: true } : {}
|
|
2240
|
+
});
|
|
1792
2241
|
if (delay === null) {
|
|
1793
2242
|
throw normalized;
|
|
1794
2243
|
}
|
|
2244
|
+
retryWaitMs += delay;
|
|
1795
2245
|
attempt += 1;
|
|
1796
2246
|
await sleep(delay, resolved.signal, requestId);
|
|
1797
2247
|
}
|
|
1798
2248
|
}
|
|
1799
2249
|
};
|
|
2250
|
+
|
|
1800
2251
|
let warnedOptionsKeyedState = false;
|
|
1801
2252
|
const warnOptionsKeyedState = (onHookError, diagnostics) => {
|
|
1802
2253
|
if (warnedOptionsKeyedState) {
|
|
@@ -1846,29 +2297,6 @@ const buildHeaders = (resolvedAuth, contentType) => {
|
|
|
1846
2297
|
}
|
|
1847
2298
|
return headers;
|
|
1848
2299
|
};
|
|
1849
|
-
const tryCacheRead = (resolved, method, url, data, cacheAuthKey) => {
|
|
1850
|
-
const cached = resolved.responseCache.get(method, url, data, cacheAuthKey);
|
|
1851
|
-
if (cached === void 0) {
|
|
1852
|
-
return void 0;
|
|
1853
|
-
}
|
|
1854
|
-
if (resolved.onRequestStart !== void 0 || resolved.onResponse !== void 0) {
|
|
1855
|
-
const requestId = randomUUID();
|
|
1856
|
-
const hookContext = { requestId, url, method, attempt: 1 };
|
|
1857
|
-
safeInvoke(
|
|
1858
|
-
resolved.onRequestStart,
|
|
1859
|
-
"onRequestStart",
|
|
1860
|
-
resolved.onHookError,
|
|
1861
|
-
resolved.diagnostics,
|
|
1862
|
-
hookContext
|
|
1863
|
-
);
|
|
1864
|
-
safeInvoke(resolved.onResponse, "onResponse", resolved.onHookError, resolved.diagnostics, {
|
|
1865
|
-
...hookContext,
|
|
1866
|
-
durationMs: 0,
|
|
1867
|
-
cacheHit: true
|
|
1868
|
-
});
|
|
1869
|
-
}
|
|
1870
|
-
return cached;
|
|
1871
|
-
};
|
|
1872
2300
|
const sendRequest = async (url, method, data, auth, sendOptions) => {
|
|
1873
2301
|
const {
|
|
1874
2302
|
requiresAuth = false,
|
|
@@ -1880,21 +2308,13 @@ const sendRequest = async (url, method, data, auth, sendOptions) => {
|
|
|
1880
2308
|
} = sendOptions ?? {};
|
|
1881
2309
|
const isRestCall = protocol === "rest" || protocol === void 0 && contentType !== void 0;
|
|
1882
2310
|
const resolvedAuth = resolveAuthMaterial(auth);
|
|
1883
|
-
|
|
1884
|
-
throw new AniLinkAuthError(operation);
|
|
1885
|
-
}
|
|
2311
|
+
const authGuard = requiresAuth && !resolvedAuth.hasAuthMaterial ? () => new AniLinkAuthError(operation) : void 0;
|
|
1886
2312
|
const headers = buildHeaders(resolvedAuth, contentType);
|
|
1887
2313
|
const resolved = resolveRequestOptions(options);
|
|
1888
2314
|
if (stateOwner === void 0 && options !== void 0 && (resolved.circuitBreaker !== void 0 || resolved.retryBudget !== void 0 || resolved.paceWithRateLimit)) {
|
|
1889
2315
|
warnOptionsKeyedState(resolved.onHookError, resolved.diagnostics);
|
|
1890
2316
|
}
|
|
1891
|
-
const cacheAuthKey = resolveCacheAuthKey(
|
|
1892
|
-
resolved.responseCache,
|
|
1893
|
-
method,
|
|
1894
|
-
data,
|
|
1895
|
-
isRestCall,
|
|
1896
|
-
resolvedAuth
|
|
1897
|
-
);
|
|
2317
|
+
const cacheAuthKey = authGuard !== void 0 || resolved.bypassResponseCache ? void 0 : resolveCacheAuthKey(resolved.responseCache, method, data, isRestCall, resolvedAuth);
|
|
1898
2318
|
let cacheGenerationAtRead;
|
|
1899
2319
|
if (cacheAuthKey !== void 0) {
|
|
1900
2320
|
const cached = tryCacheRead(resolved, method, url, data, cacheAuthKey);
|
|
@@ -1903,53 +2323,32 @@ const sendRequest = async (url, method, data, auth, sendOptions) => {
|
|
|
1903
2323
|
}
|
|
1904
2324
|
cacheGenerationAtRead = resolved.responseCache.getGeneration();
|
|
1905
2325
|
}
|
|
1906
|
-
const
|
|
2326
|
+
const writeBack = cacheAuthKey !== void 0 ? (result2, resolvedPartial) => resolvedPartial ? false : resolved.responseCache.setIfFresh(
|
|
2327
|
+
method,
|
|
2328
|
+
url,
|
|
2329
|
+
data,
|
|
2330
|
+
cacheAuthKey,
|
|
2331
|
+
cacheGenerationAtRead,
|
|
2332
|
+
result2,
|
|
2333
|
+
isRestCall ? void 0 : extractQueryRootField(data)
|
|
2334
|
+
) : void 0;
|
|
2335
|
+
const { result } = await executeWithRetry(
|
|
1907
2336
|
{ url, method, data, headers },
|
|
1908
2337
|
resolved,
|
|
1909
2338
|
stateOwner ?? options,
|
|
1910
2339
|
{
|
|
1911
2340
|
rawPassthrough: isRestCall,
|
|
1912
|
-
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
// (no cache key) and mutations stay unmarked.
|
|
1916
|
-
cacheMiss: cacheAuthKey !== void 0
|
|
2341
|
+
cacheMiss: cacheAuthKey !== void 0,
|
|
2342
|
+
authGuard,
|
|
2343
|
+
writeBack
|
|
1917
2344
|
}
|
|
1918
2345
|
);
|
|
1919
|
-
if (cacheAuthKey
|
|
1920
|
-
if (!resolvedPartial) {
|
|
1921
|
-
resolved.responseCache.setIfFresh(
|
|
1922
|
-
method,
|
|
1923
|
-
url,
|
|
1924
|
-
data,
|
|
1925
|
-
cacheAuthKey,
|
|
1926
|
-
cacheGenerationAtRead,
|
|
1927
|
-
result
|
|
1928
|
-
);
|
|
1929
|
-
}
|
|
1930
|
-
} else {
|
|
2346
|
+
if (cacheAuthKey === void 0) {
|
|
1931
2347
|
invalidateAfterMutation(resolved.responseCache, method, url, data, isRestCall);
|
|
1932
2348
|
}
|
|
1933
2349
|
return result;
|
|
1934
2350
|
};
|
|
1935
2351
|
|
|
1936
|
-
const DEEP_MERGED_OPTION_KEYS = ["retry", "circuitBreaker", "retryBudget"];
|
|
1937
|
-
const assignDeepMergedOption = (merged, key, value) => {
|
|
1938
|
-
merged[key] = value;
|
|
1939
|
-
};
|
|
1940
|
-
const mergeOptions = (base, overrides) => {
|
|
1941
|
-
if (overrides === void 0) return base;
|
|
1942
|
-
if (base === void 0) return overrides;
|
|
1943
|
-
const merged = { ...base, ...overrides };
|
|
1944
|
-
for (const key of DEEP_MERGED_OPTION_KEYS) {
|
|
1945
|
-
const baseValue = base[key];
|
|
1946
|
-
const overrideValue = overrides[key];
|
|
1947
|
-
if (typeof baseValue === "object" && baseValue !== null && typeof overrideValue === "object" && overrideValue !== null) {
|
|
1948
|
-
assignDeepMergedOption(merged, key, { ...baseValue, ...overrideValue });
|
|
1949
|
-
}
|
|
1950
|
-
}
|
|
1951
|
-
return merged;
|
|
1952
|
-
};
|
|
1953
2352
|
const resolveOperationLabel = (operation) => {
|
|
1954
2353
|
const name = operation.constructor?.name;
|
|
1955
2354
|
return typeof name === "string" && name.length > 0 ? name : void 0;
|
|
@@ -1970,8 +2369,8 @@ class BaseOperation {
|
|
|
1970
2369
|
/**
|
|
1971
2370
|
* The authentication token shared by all operations of an instance.
|
|
1972
2371
|
*
|
|
1973
|
-
* Mutable only through {@link BaseOperation.updateAuth} so
|
|
1974
|
-
* wiring
|
|
2372
|
+
* Mutable only through {@link BaseOperation.updateAuth} so provider
|
|
2373
|
+
* wiring can swap in refreshed auth material (for example the MAL
|
|
1975
2374
|
* automatic token-refresh lifecycle) without rebuilding operations.
|
|
1976
2375
|
*/
|
|
1977
2376
|
requestAuth;
|
|
@@ -2018,10 +2417,10 @@ class BaseOperation {
|
|
|
2018
2417
|
/**
|
|
2019
2418
|
* Swaps the instance authentication material in place.
|
|
2020
2419
|
*
|
|
2021
|
-
* @internal This mutator exists for provider wiring
|
|
2420
|
+
* @internal This mutator exists for provider wiring that refreshes
|
|
2022
2421
|
* credentials mid-flight (the MAL automatic token-refresh lifecycle swaps
|
|
2023
2422
|
* the stored auth on the operation instances before replaying a 401'd
|
|
2024
|
-
* request). It is not part of the public API
|
|
2423
|
+
* request). It is not part of the public API.
|
|
2025
2424
|
*
|
|
2026
2425
|
* @param auth - The replacement authentication material, or `undefined` to clear it.
|
|
2027
2426
|
*/
|
|
@@ -2031,12 +2430,11 @@ class BaseOperation {
|
|
|
2031
2430
|
/**
|
|
2032
2431
|
* Reads the current instance authentication material.
|
|
2033
2432
|
*
|
|
2034
|
-
* @internal Companion to {@link BaseOperation.updateAuth} for
|
|
2035
|
-
*
|
|
2433
|
+
* @internal Companion to {@link BaseOperation.updateAuth} for provider
|
|
2434
|
+
* wiring that rebuilds auth from the operation's current state at swap time
|
|
2036
2435
|
* (the MAL refresh lifecycle reads each operation's current auth before
|
|
2037
2436
|
* applying a fresh access token, so a replay never rebuilds headers from
|
|
2038
|
-
* a stale construction-time snapshot). It is not part of the public API
|
|
2039
|
-
* surface.
|
|
2437
|
+
* a stale construction-time snapshot). It is not part of the public API.
|
|
2040
2438
|
*
|
|
2041
2439
|
* @returns The current {@link RequestAuthInput}, or `undefined`.
|
|
2042
2440
|
*/
|
|
@@ -2070,7 +2468,7 @@ class BaseOperation {
|
|
|
2070
2468
|
// The stable owner keys cross-request transport state (circuit
|
|
2071
2469
|
// breaker, retry budget, pacing deadlines) so failure streaks
|
|
2072
2470
|
// accumulate across requests even when each call carries fresh
|
|
2073
|
-
// per-request options
|
|
2471
|
+
// per-request options, across the whole client when the wiring
|
|
2074
2472
|
// shared one owner, or across this instance's requests otherwise.
|
|
2075
2473
|
stateOwner: this.stateOwner
|
|
2076
2474
|
});
|
|
@@ -2264,8 +2662,8 @@ class AniListOperation extends BaseOperation {
|
|
|
2264
2662
|
* Runs the shared validate-then-dispatch pipeline for an operation.
|
|
2265
2663
|
*
|
|
2266
2664
|
* Operations declare their contract as a {@link AniListExecuteOptions}
|
|
2267
|
-
* object
|
|
2268
|
-
* auth requirement
|
|
2665
|
+
* object: variable-presence requirements, an optional type map, and the
|
|
2666
|
+
* auth requirement. This method applies them in a fixed order before
|
|
2269
2667
|
* delegating to {@link AniListOperation.request}.
|
|
2270
2668
|
*
|
|
2271
2669
|
* @param query - The GraphQL document to execute.
|
|
@@ -2278,144 +2676,17 @@ class AniListOperation extends BaseOperation {
|
|
|
2278
2676
|
*/
|
|
2279
2677
|
async execute(query, variables, options) {
|
|
2280
2678
|
const { requirements, mappings, requiresAuth, transportOptions } = options;
|
|
2281
|
-
const variablesForRequirements = variables ?? {};
|
|
2282
|
-
if (requirements) {
|
|
2283
|
-
for (const requirement of requirements) {
|
|
2284
|
-
requireVariables(variablesForRequirements, requirement, requirement.message);
|
|
2285
|
-
}
|
|
2286
|
-
}
|
|
2287
|
-
if (mappings && variables !== void 0) {
|
|
2288
|
-
validateVariables(variables, mappings);
|
|
2289
|
-
}
|
|
2290
|
-
return await this.request(query, variables, { requiresAuth, transportOptions });
|
|
2291
|
-
}
|
|
2292
|
-
}
|
|
2293
|
-
|
|
2294
|
-
const GRAPHQL_OPERATION_PATTERN = /^\s*(?:\{|(?:query|mutation)\b[\s\S]*\{)/;
|
|
2295
|
-
const stripLeadingComments = (query) => {
|
|
2296
|
-
let rest = query;
|
|
2297
|
-
for (; ; ) {
|
|
2298
|
-
const line = /^[^\n]*\n/.exec(rest);
|
|
2299
|
-
if (line === null) {
|
|
2300
|
-
return rest;
|
|
2301
|
-
}
|
|
2302
|
-
if (!/^\s*(#|$)/.test(line[0])) {
|
|
2303
|
-
return rest;
|
|
2304
|
-
}
|
|
2305
|
-
rest = rest.slice(line[0].length);
|
|
2306
|
-
}
|
|
2307
|
-
};
|
|
2308
|
-
class CustomRequest extends AniListOperation {
|
|
2309
|
-
/**
|
|
2310
|
-
* `custom` sends a caller-authored GraphQL query or mutation document.
|
|
2311
|
-
*
|
|
2312
|
-
* The response follows the same unwrapping rule as every other operation:
|
|
2313
|
-
* a document with a single root field resolves to the bare field value,
|
|
2314
|
-
* while a document with multiple root fields resolves to the full
|
|
2315
|
-
* `{ data }` envelope. Annotate `T` with the shape you expect — the bare
|
|
2316
|
-
* value for single-root-field documents, or the envelope type itself when
|
|
2317
|
-
* the document selects several root fields:
|
|
2318
|
-
*
|
|
2319
|
-
* ```typescript
|
|
2320
|
-
* const viewer = await aniLink.anilist.custom<{ id: number }>("query { Viewer { id } }");
|
|
2321
|
-
*
|
|
2322
|
-
* // Multi-root-field document: T is the full envelope.
|
|
2323
|
-
* const both = await aniLink.anilist.custom<{ data: { Media: { id: number }; User: { id: number } } }>(
|
|
2324
|
-
* "query { Media (id: 1) { id } User (id: 1) { id } }"
|
|
2325
|
-
* );
|
|
2326
|
-
* ```
|
|
2327
|
-
*
|
|
2328
|
-
* @param query - The GraphQL document to execute. It must declare an executable operation: an anonymous shorthand selection (`{ Viewer { id } }`) or a `query`/`mutation` document. AniList's HTTP endpoint does not serve subscriptions, so `subscription` documents are rejected here rather than failing remotely.
|
|
2329
|
-
* @param variables - The variables for the document. This parameter is optional.
|
|
2330
|
-
* @param options - Optional per-request transport settings merged over the instance-level ones for this call only.
|
|
2331
|
-
* @returns A promise that resolves to the unwrapped response data for single-root-field documents, or the full `{ data }` envelope otherwise.
|
|
2332
|
-
* @throws An {@link AniLinkValidationError} when the query is empty or is not an executable document (for example a fragment-only definition).
|
|
2333
|
-
* @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.
|
|
2334
|
-
* @see https://docs.anilist.co/reference/query
|
|
2335
|
-
* @see https://docs.anilist.co/reference/mutation
|
|
2336
|
-
*/
|
|
2337
|
-
async custom(query, variables = {}, options) {
|
|
2338
|
-
if (typeof query !== "string" || !GRAPHQL_OPERATION_PATTERN.test(stripLeadingComments(query))) {
|
|
2339
|
-
throw new AniLinkValidationError([
|
|
2340
|
-
"custom() requires an executable GraphQL document (an anonymous selection or a query/mutation operation)"
|
|
2341
|
-
]);
|
|
2342
|
-
}
|
|
2343
|
-
return await this.request(query, variables, { transportOptions: options });
|
|
2344
|
-
}
|
|
2345
|
-
}
|
|
2346
|
-
|
|
2347
|
-
function fuzzyDate(options) {
|
|
2348
|
-
const input = {
|
|
2349
|
-
year: options?.year ?? 0,
|
|
2350
|
-
month: options?.month ?? 0,
|
|
2351
|
-
day: options?.day ?? 0
|
|
2352
|
-
};
|
|
2353
|
-
return input;
|
|
2354
|
-
}
|
|
2355
|
-
|
|
2356
|
-
function fuzzyDateInt(options) {
|
|
2357
|
-
const year = options?.year ?? 0;
|
|
2358
|
-
const month = options?.month ?? 0;
|
|
2359
|
-
const day = options?.day ?? 0;
|
|
2360
|
-
return year * 1e4 + month * 100 + day;
|
|
2361
|
-
}
|
|
2362
|
-
|
|
2363
|
-
function flattenMediaListCollection(response) {
|
|
2364
|
-
if (!response || !Array.isArray(response.lists)) {
|
|
2365
|
-
return [];
|
|
2366
|
-
}
|
|
2367
|
-
const byId = /* @__PURE__ */ new Map();
|
|
2368
|
-
for (const group of response.lists) {
|
|
2369
|
-
if (!Array.isArray(group.entries)) {
|
|
2370
|
-
continue;
|
|
2371
|
-
}
|
|
2372
|
-
for (const entry of group.entries) {
|
|
2373
|
-
mergeEntry(byId, entry, group.name, group.isCustomList, group.isSplitCompletedList);
|
|
2374
|
-
}
|
|
2375
|
-
}
|
|
2376
|
-
return Array.from(byId.values());
|
|
2377
|
-
}
|
|
2378
|
-
function mergeEntry(byId, entry, listName, isCustomList, isSplitCompletedList) {
|
|
2379
|
-
if (typeof entry !== "object" || entry === null || typeof entry.id !== "number" || !Number.isFinite(entry.id)) {
|
|
2380
|
-
throw new AniLinkValidationError([
|
|
2381
|
-
`flattenMediaListCollection: a list entry in "${listName}" is malformed \u2014 every entry must be an object with a finite numeric id.`
|
|
2382
|
-
]);
|
|
2383
|
-
}
|
|
2384
|
-
const existing = byId.get(entry.id);
|
|
2385
|
-
if (existing) {
|
|
2386
|
-
if (!existing.listNames.includes(listName)) {
|
|
2387
|
-
existing.listNames.push(listName);
|
|
2388
|
-
}
|
|
2389
|
-
if (isCustomList) existing.inCustomList = true;
|
|
2390
|
-
if (isSplitCompletedList) existing.inSplitCompletedList = true;
|
|
2391
|
-
return;
|
|
2392
|
-
}
|
|
2393
|
-
byId.set(entry.id, {
|
|
2394
|
-
id: entry.id,
|
|
2395
|
-
userId: entry.userId,
|
|
2396
|
-
mediaId: entry.mediaId,
|
|
2397
|
-
status: entry.status,
|
|
2398
|
-
score: entry.score,
|
|
2399
|
-
progress: entry.progress,
|
|
2400
|
-
listNames: [listName],
|
|
2401
|
-
inCustomList: isCustomList,
|
|
2402
|
-
inSplitCompletedList: isSplitCompletedList
|
|
2403
|
-
});
|
|
2404
|
-
}
|
|
2405
|
-
|
|
2406
|
-
function crossLink(media) {
|
|
2407
|
-
const anilistToMal = /* @__PURE__ */ new Map();
|
|
2408
|
-
const malToAnilist = /* @__PURE__ */ new Map();
|
|
2409
|
-
const unmapped = [];
|
|
2410
|
-
for (const entry of media) {
|
|
2411
|
-
if (typeof entry.idMal !== "number") {
|
|
2412
|
-
unmapped.push(entry);
|
|
2413
|
-
continue;
|
|
2679
|
+
const variablesForRequirements = variables ?? {};
|
|
2680
|
+
if (requirements) {
|
|
2681
|
+
for (const requirement of requirements) {
|
|
2682
|
+
requireVariables(variablesForRequirements, requirement, requirement.message);
|
|
2683
|
+
}
|
|
2414
2684
|
}
|
|
2415
|
-
|
|
2416
|
-
|
|
2685
|
+
if (mappings && variables !== void 0) {
|
|
2686
|
+
validateVariables(variables, mappings);
|
|
2687
|
+
}
|
|
2688
|
+
return await this.request(query, variables, { requiresAuth, transportOptions });
|
|
2417
2689
|
}
|
|
2418
|
-
return { anilistToMal, malToAnilist, unmapped };
|
|
2419
2690
|
}
|
|
2420
2691
|
|
|
2421
2692
|
const MAX_CONCURRENCY = 8;
|
|
@@ -2429,6 +2700,32 @@ function resolvePositiveInt(value, fallback, name = "option") {
|
|
|
2429
2700
|
function resolveCappedInt(value, max, fallback, name = "option") {
|
|
2430
2701
|
return Math.min(resolvePositiveInt(value, fallback, name), max);
|
|
2431
2702
|
}
|
|
2703
|
+
function resolvePaginationOptions(options, defaults) {
|
|
2704
|
+
const perEntryName = defaults.naming === "page" ? "perPage" : "perChunk";
|
|
2705
|
+
const startName = defaults.naming === "page" ? "startPage" : "startChunk";
|
|
2706
|
+
const maxEntriesName = defaults.naming === "page" ? "maxPages" : "maxChunks";
|
|
2707
|
+
return {
|
|
2708
|
+
perEntry: resolveCappedInt(
|
|
2709
|
+
options?.[perEntryName],
|
|
2710
|
+
defaults.maxPerEntry,
|
|
2711
|
+
defaults.defaultPerEntry,
|
|
2712
|
+
perEntryName
|
|
2713
|
+
),
|
|
2714
|
+
startEntry: resolvePositiveInt(options?.[startName], 1, startName),
|
|
2715
|
+
maxEntries: resolvePositiveInt(
|
|
2716
|
+
options?.[maxEntriesName],
|
|
2717
|
+
defaults.defaultMaxEntries,
|
|
2718
|
+
maxEntriesName
|
|
2719
|
+
),
|
|
2720
|
+
concurrency: resolveCappedInt(
|
|
2721
|
+
options?.concurrency,
|
|
2722
|
+
MAX_CONCURRENCY,
|
|
2723
|
+
defaults.defaultConcurrency,
|
|
2724
|
+
"concurrency"
|
|
2725
|
+
),
|
|
2726
|
+
diagnostics: resolveDiagnosticsMode(options?.diagnostics)
|
|
2727
|
+
};
|
|
2728
|
+
}
|
|
2432
2729
|
function bridgeAbortSignal(external) {
|
|
2433
2730
|
const controller = new AbortController();
|
|
2434
2731
|
if (external === void 0) {
|
|
@@ -2449,30 +2746,7 @@ function bridgeAbortSignal(external) {
|
|
|
2449
2746
|
}
|
|
2450
2747
|
};
|
|
2451
2748
|
}
|
|
2452
|
-
async function fetchWithLookAhead(
|
|
2453
|
-
if (typeof args[2] === "function") {
|
|
2454
|
-
const [fetch2, extractHasMore2, extractNextKey, firstKey, maxEntries2, , signal2] = args;
|
|
2455
|
-
return fetchCursorChain(
|
|
2456
|
-
fetch2,
|
|
2457
|
-
extractHasMore2,
|
|
2458
|
-
extractNextKey,
|
|
2459
|
-
firstKey,
|
|
2460
|
-
maxEntries2,
|
|
2461
|
-
signal2
|
|
2462
|
-
);
|
|
2463
|
-
}
|
|
2464
|
-
if (args[2] === void 0) {
|
|
2465
|
-
const [fetch2, extractHasMore2, , startNumber2, maxEntries2, concurrency2, signal2] = args;
|
|
2466
|
-
return fetchNumericWithLookAhead(
|
|
2467
|
-
fetch2,
|
|
2468
|
-
extractHasMore2,
|
|
2469
|
-
startNumber2,
|
|
2470
|
-
maxEntries2,
|
|
2471
|
-
concurrency2,
|
|
2472
|
-
signal2
|
|
2473
|
-
);
|
|
2474
|
-
}
|
|
2475
|
-
const [fetch, extractHasMore, startNumber, maxEntries, concurrency, signal, extractBound] = args;
|
|
2749
|
+
async function fetchWithLookAhead(fetch, extractHasMore, startNumber, maxEntries, concurrency, signal, extractBound) {
|
|
2476
2750
|
return fetchNumericWithLookAhead(
|
|
2477
2751
|
fetch,
|
|
2478
2752
|
extractHasMore,
|
|
@@ -2501,13 +2775,14 @@ async function fetchNumericWithLookAhead(fetch, extractHasMore, startNumber, max
|
|
|
2501
2775
|
let truncated = false;
|
|
2502
2776
|
const readBound = extractBound ?? (() => void 0);
|
|
2503
2777
|
let lastPageBound = Number.POSITIVE_INFINITY;
|
|
2778
|
+
let windowSize = Math.min(1, concurrency);
|
|
2504
2779
|
while (count < maxEntries) {
|
|
2505
2780
|
if (signal?.aborted) {
|
|
2506
2781
|
await Promise.allSettled(pending.slice(count));
|
|
2507
2782
|
responses.length = count;
|
|
2508
2783
|
return { responses, count, truncated: false };
|
|
2509
2784
|
}
|
|
2510
|
-
while (launched < maxEntries && launched - count <
|
|
2785
|
+
while (launched < maxEntries && launched - count < windowSize && startNumber + launched <= lastPageBound) {
|
|
2511
2786
|
const slot = launched;
|
|
2512
2787
|
launched += 1;
|
|
2513
2788
|
const request = fetch(startNumber + slot).then((response) => {
|
|
@@ -2543,6 +2818,7 @@ async function fetchNumericWithLookAhead(fetch, extractHasMore, startNumber, max
|
|
|
2543
2818
|
responses.length = count;
|
|
2544
2819
|
return { responses, count, truncated: false };
|
|
2545
2820
|
}
|
|
2821
|
+
windowSize = concurrency;
|
|
2546
2822
|
if (count >= maxEntries) {
|
|
2547
2823
|
await Promise.allSettled(pending.slice(count));
|
|
2548
2824
|
truncated = true;
|
|
@@ -2551,38 +2827,6 @@ async function fetchNumericWithLookAhead(fetch, extractHasMore, startNumber, max
|
|
|
2551
2827
|
}
|
|
2552
2828
|
return { responses, count, truncated };
|
|
2553
2829
|
}
|
|
2554
|
-
async function fetchCursorChain(fetch, extractHasMore, extractNextKey, firstKey, maxEntries, signal) {
|
|
2555
|
-
const responses = [];
|
|
2556
|
-
let key = firstKey;
|
|
2557
|
-
while (responses.length < maxEntries && key !== void 0) {
|
|
2558
|
-
if (signal?.aborted) {
|
|
2559
|
-
return { responses, count: responses.length, truncated: false };
|
|
2560
|
-
}
|
|
2561
|
-
let entry;
|
|
2562
|
-
try {
|
|
2563
|
-
entry = await fetch(key);
|
|
2564
|
-
} catch (err) {
|
|
2565
|
-
if (signal?.aborted) {
|
|
2566
|
-
return { responses, count: responses.length, truncated: false };
|
|
2567
|
-
}
|
|
2568
|
-
throw err;
|
|
2569
|
-
}
|
|
2570
|
-
responses.push(entry);
|
|
2571
|
-
if (!extractHasMore(entry)) {
|
|
2572
|
-
return { responses, count: responses.length, truncated: false };
|
|
2573
|
-
}
|
|
2574
|
-
key = extractNextKey(entry);
|
|
2575
|
-
}
|
|
2576
|
-
return {
|
|
2577
|
-
responses,
|
|
2578
|
-
count: responses.length,
|
|
2579
|
-
// A degenerate guard (maxEntries <= 0) fetched nothing and cut
|
|
2580
|
-
// nothing short: `truncated` reports whether the guard ended a run
|
|
2581
|
-
// that still had data, matching the numeric driver's `while (count <
|
|
2582
|
-
// maxEntries)` early exit.
|
|
2583
|
-
truncated: responses.length >= maxEntries && maxEntries > 0
|
|
2584
|
-
};
|
|
2585
|
-
}
|
|
2586
2830
|
async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBound) {
|
|
2587
2831
|
const { perPage, startPage, maxPages, concurrency } = options;
|
|
2588
2832
|
const { signal, dispose } = bridgeAbortSignal(options.signal);
|
|
@@ -2596,8 +2840,9 @@ async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBo
|
|
|
2596
2840
|
let terminal = false;
|
|
2597
2841
|
let launchBound = Number.POSITIVE_INFINITY;
|
|
2598
2842
|
const readBound = extractBound ?? (() => void 0);
|
|
2599
|
-
|
|
2600
|
-
|
|
2843
|
+
let windowSize = Math.min(1, concurrency);
|
|
2844
|
+
const launchWindow = (limit) => {
|
|
2845
|
+
while (!terminal && nextToLaunch - startPage < maxPages && pending.size < limit && nextToLaunch <= launchBound) {
|
|
2601
2846
|
const page = nextToLaunch;
|
|
2602
2847
|
nextToLaunch += 1;
|
|
2603
2848
|
const request = fetchPage(page, perPage, signal);
|
|
@@ -2608,7 +2853,7 @@ async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBo
|
|
|
2608
2853
|
};
|
|
2609
2854
|
try {
|
|
2610
2855
|
while (nextToYield - startPage < maxPages) {
|
|
2611
|
-
launchWindow();
|
|
2856
|
+
launchWindow(windowSize);
|
|
2612
2857
|
const page = nextToYield;
|
|
2613
2858
|
const request = pending.get(page);
|
|
2614
2859
|
if (request === void 0) {
|
|
@@ -2629,8 +2874,13 @@ async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBo
|
|
|
2629
2874
|
if (observedBound !== void 0 && observedBound < launchBound) {
|
|
2630
2875
|
launchBound = observedBound;
|
|
2631
2876
|
}
|
|
2877
|
+
const pageIsTerminal = isTerminalPage(response);
|
|
2878
|
+
if (!pageIsTerminal) {
|
|
2879
|
+
windowSize = concurrency;
|
|
2880
|
+
launchWindow(concurrency - 1);
|
|
2881
|
+
}
|
|
2632
2882
|
yield response;
|
|
2633
|
-
if (
|
|
2883
|
+
if (pageIsTerminal) {
|
|
2634
2884
|
terminal = true;
|
|
2635
2885
|
await Promise.allSettled([...pending.values()]);
|
|
2636
2886
|
break;
|
|
@@ -2642,8 +2892,8 @@ async function* streamNumericPages(fetchPage, isTerminalPage, options, extractBo
|
|
|
2642
2892
|
}
|
|
2643
2893
|
}
|
|
2644
2894
|
|
|
2645
|
-
const DEFAULT_PER_PAGE$
|
|
2646
|
-
const MAX_PER_PAGE$
|
|
2895
|
+
const DEFAULT_PER_PAGE$2 = 50;
|
|
2896
|
+
const MAX_PER_PAGE$2 = DEFAULT_PER_PAGE$2;
|
|
2647
2897
|
const DEFAULT_MAX_PAGES$1 = 100;
|
|
2648
2898
|
const DEFAULT_PER_CHUNK = 500;
|
|
2649
2899
|
const MAX_PER_CHUNK = DEFAULT_PER_CHUNK;
|
|
@@ -2652,6 +2902,20 @@ const safeCallback$1 = (callback, name, onHookError, diagnostics, payload) => {
|
|
|
2652
2902
|
safeInvoke(callback, name, onHookError, diagnostics, payload);
|
|
2653
2903
|
};
|
|
2654
2904
|
const DEFAULT_CONCURRENCY$1 = 3;
|
|
2905
|
+
const PAGE_DEFAULTS = {
|
|
2906
|
+
naming: "page",
|
|
2907
|
+
maxPerEntry: MAX_PER_PAGE$2,
|
|
2908
|
+
defaultPerEntry: DEFAULT_PER_PAGE$2,
|
|
2909
|
+
defaultMaxEntries: DEFAULT_MAX_PAGES$1,
|
|
2910
|
+
defaultConcurrency: DEFAULT_CONCURRENCY$1
|
|
2911
|
+
};
|
|
2912
|
+
const CHUNK_DEFAULTS = {
|
|
2913
|
+
naming: "chunk",
|
|
2914
|
+
maxPerEntry: MAX_PER_CHUNK,
|
|
2915
|
+
defaultPerEntry: DEFAULT_PER_CHUNK,
|
|
2916
|
+
defaultMaxEntries: DEFAULT_MAX_CHUNKS,
|
|
2917
|
+
defaultConcurrency: DEFAULT_CONCURRENCY$1
|
|
2918
|
+
};
|
|
2655
2919
|
function extractHasMore$1(response) {
|
|
2656
2920
|
if (typeof response !== "object" || response === null) return false;
|
|
2657
2921
|
if ("pageInfo" in response) {
|
|
@@ -2664,16 +2928,13 @@ function extractHasMore$1(response) {
|
|
|
2664
2928
|
return response.hasNextChunk === true;
|
|
2665
2929
|
}
|
|
2666
2930
|
async function paginate(fetchPage, itemsKey, options) {
|
|
2667
|
-
const
|
|
2668
|
-
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
"concurrency"
|
|
2675
|
-
);
|
|
2676
|
-
const diagnostics = resolveDiagnosticsMode(options?.diagnostics);
|
|
2931
|
+
const {
|
|
2932
|
+
perEntry: perPage,
|
|
2933
|
+
startEntry: startPage,
|
|
2934
|
+
maxEntries: maxPages,
|
|
2935
|
+
concurrency,
|
|
2936
|
+
diagnostics
|
|
2937
|
+
} = resolvePaginationOptions(options, PAGE_DEFAULTS);
|
|
2677
2938
|
const { signal, dispose } = bridgeAbortSignal(options?.signal);
|
|
2678
2939
|
try {
|
|
2679
2940
|
const { responses, count, truncated } = await fetchWithLookAhead(
|
|
@@ -2685,92 +2946,500 @@ async function paginate(fetchPage, itemsKey, options) {
|
|
|
2685
2946
|
signal,
|
|
2686
2947
|
extractLastPageBound
|
|
2687
2948
|
);
|
|
2688
|
-
const items = [];
|
|
2689
|
-
const pages = [];
|
|
2690
|
-
for (const response of responses) {
|
|
2691
|
-
const raw = response[itemsKey];
|
|
2692
|
-
if (raw === void 0) {
|
|
2693
|
-
throw new AniLinkValidationError([
|
|
2694
|
-
`paginate: the page response has no "${itemsKey}" key. Check the itemsKey argument.`
|
|
2695
|
-
]);
|
|
2949
|
+
const items = [];
|
|
2950
|
+
const pages = [];
|
|
2951
|
+
for (const response of responses) {
|
|
2952
|
+
const raw = response[itemsKey];
|
|
2953
|
+
if (raw === void 0) {
|
|
2954
|
+
throw new AniLinkValidationError([
|
|
2955
|
+
`paginate: the page response has no "${itemsKey}" key. Check the itemsKey argument.`
|
|
2956
|
+
]);
|
|
2957
|
+
}
|
|
2958
|
+
const pageItems = Array.isArray(raw) ? raw : [];
|
|
2959
|
+
pages.push({ pageInfo: response.pageInfo, items: pageItems });
|
|
2960
|
+
items.push(...pageItems);
|
|
2961
|
+
safeCallback$1(options?.onPage, "onPage", options?.onHookError, diagnostics, {
|
|
2962
|
+
pageInfo: response.pageInfo,
|
|
2963
|
+
items: pageItems
|
|
2964
|
+
});
|
|
2965
|
+
}
|
|
2966
|
+
return { items, pages, pageCount: count, truncated };
|
|
2967
|
+
} finally {
|
|
2968
|
+
dispose();
|
|
2969
|
+
}
|
|
2970
|
+
}
|
|
2971
|
+
async function* paginatePages(fetchPage, options) {
|
|
2972
|
+
const {
|
|
2973
|
+
perEntry: perPage,
|
|
2974
|
+
startEntry: startPage,
|
|
2975
|
+
maxEntries: maxPages,
|
|
2976
|
+
concurrency
|
|
2977
|
+
} = resolvePaginationOptions(options, PAGE_DEFAULTS);
|
|
2978
|
+
yield* streamNumericPages(
|
|
2979
|
+
fetchPage,
|
|
2980
|
+
(response) => !response.pageInfo.hasNextPage,
|
|
2981
|
+
{ perPage, startPage, maxPages, concurrency, signal: options?.signal },
|
|
2982
|
+
extractLastPageBound
|
|
2983
|
+
);
|
|
2984
|
+
}
|
|
2985
|
+
async function paginateChunks(fetchChunk, itemsKey, options) {
|
|
2986
|
+
const {
|
|
2987
|
+
perEntry: perChunk,
|
|
2988
|
+
startEntry: startChunk,
|
|
2989
|
+
maxEntries: maxChunks,
|
|
2990
|
+
concurrency,
|
|
2991
|
+
diagnostics
|
|
2992
|
+
} = resolvePaginationOptions(options, CHUNK_DEFAULTS);
|
|
2993
|
+
const { signal, dispose } = bridgeAbortSignal(options?.signal);
|
|
2994
|
+
try {
|
|
2995
|
+
const { responses, count, truncated } = await fetchWithLookAhead(
|
|
2996
|
+
(number) => fetchChunk(number, perChunk, signal),
|
|
2997
|
+
extractHasMore$1,
|
|
2998
|
+
startChunk,
|
|
2999
|
+
maxChunks,
|
|
3000
|
+
concurrency,
|
|
3001
|
+
signal
|
|
3002
|
+
);
|
|
3003
|
+
const items = [];
|
|
3004
|
+
const chunks = [];
|
|
3005
|
+
for (const response of responses) {
|
|
3006
|
+
const raw = response[itemsKey];
|
|
3007
|
+
if (raw === void 0) {
|
|
3008
|
+
throw new AniLinkValidationError([
|
|
3009
|
+
`paginateChunks: the chunk response has no "${itemsKey}" key. Check the itemsKey argument.`
|
|
3010
|
+
]);
|
|
3011
|
+
}
|
|
3012
|
+
const chunkItems = Array.isArray(raw) ? raw : [];
|
|
3013
|
+
chunks.push({ hasNextChunk: response.hasNextChunk, items: chunkItems });
|
|
3014
|
+
items.push(...chunkItems);
|
|
3015
|
+
safeCallback$1(options?.onChunk, "onChunk", options?.onHookError, diagnostics, {
|
|
3016
|
+
hasNextChunk: response.hasNextChunk,
|
|
3017
|
+
items: chunkItems
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
return { items, chunks, chunkCount: count, truncated };
|
|
3021
|
+
} finally {
|
|
3022
|
+
dispose();
|
|
3023
|
+
}
|
|
3024
|
+
}
|
|
3025
|
+
|
|
3026
|
+
const GRAPHQL_OPERATION_PATTERN = /^\s*(?:\{|(?:query|mutation)\b[\s\S]*\{)/;
|
|
3027
|
+
const GRAPHQL_PAGE_QUERY_PATTERN = /^\s*query\b[\s\S]*\bPage\s*\(/;
|
|
3028
|
+
const CUSTOM_PAGE_VALIDATION_MESSAGE = "customPage() requires a query document whose single root field is Page, selecting it with $page and $perPage Int variables";
|
|
3029
|
+
const stripLeadingComments = (query) => {
|
|
3030
|
+
let rest = query;
|
|
3031
|
+
for (; ; ) {
|
|
3032
|
+
const line = /^[^\n]*\n/.exec(rest);
|
|
3033
|
+
if (line === null) {
|
|
3034
|
+
return rest;
|
|
3035
|
+
}
|
|
3036
|
+
if (!/^\s*(#|$)/.test(line[0])) {
|
|
3037
|
+
return rest;
|
|
3038
|
+
}
|
|
3039
|
+
rest = rest.slice(line[0].length);
|
|
3040
|
+
}
|
|
3041
|
+
};
|
|
3042
|
+
class CustomRequest extends AniListOperation {
|
|
3043
|
+
/**
|
|
3044
|
+
* `custom` sends a caller-authored GraphQL query or mutation document.
|
|
3045
|
+
*
|
|
3046
|
+
* The response follows the same unwrapping rule as every other operation:
|
|
3047
|
+
* a document with a single root field resolves to the bare field value,
|
|
3048
|
+
* while a document with multiple root fields resolves to the full
|
|
3049
|
+
* `{ data }` envelope. Annotate `T` with the shape you expect: the bare
|
|
3050
|
+
* value for single-root-field documents, or the envelope type itself when
|
|
3051
|
+
* the document selects several root fields:
|
|
3052
|
+
*
|
|
3053
|
+
* ```typescript
|
|
3054
|
+
* const viewer = await aniLink.anilist.custom<{ id: number }>("query { Viewer { id } }");
|
|
3055
|
+
*
|
|
3056
|
+
* // Multi-root-field document: T is the full envelope.
|
|
3057
|
+
* const both = await aniLink.anilist.custom<{ data: { Media: { id: number }; User: { id: number } } }>(
|
|
3058
|
+
* "query { Media (id: 1) { id } User (id: 1) { id } }"
|
|
3059
|
+
* );
|
|
3060
|
+
* ```
|
|
3061
|
+
*
|
|
3062
|
+
* @param query - The GraphQL document to execute. It must declare an executable operation: an anonymous shorthand selection (`{ Viewer { id } }`) or a `query`/`mutation` document. AniList's HTTP endpoint does not serve subscriptions, so `subscription` documents are rejected here rather than failing remotely.
|
|
3063
|
+
* @param variables - The variables for the document. This parameter is optional.
|
|
3064
|
+
* @param options - Optional per-request transport settings merged over the instance-level ones for this call only.
|
|
3065
|
+
* @returns A promise that resolves to the unwrapped response data for single-root-field documents, or the full `{ data }` envelope otherwise.
|
|
3066
|
+
* @throws An {@link AniLinkValidationError} when the query is empty or is not an executable document (for example a fragment-only definition).
|
|
3067
|
+
* @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.
|
|
3068
|
+
* @see https://docs.anilist.co/reference/query
|
|
3069
|
+
* @see https://docs.anilist.co/reference/mutation
|
|
3070
|
+
*/
|
|
3071
|
+
async custom(query, variables = {}, options) {
|
|
3072
|
+
if (typeof query !== "string" || !GRAPHQL_OPERATION_PATTERN.test(stripLeadingComments(query))) {
|
|
3073
|
+
throw new AniLinkValidationError([
|
|
3074
|
+
"custom() requires an executable GraphQL document (an anonymous selection or a query/mutation operation)"
|
|
3075
|
+
]);
|
|
3076
|
+
}
|
|
3077
|
+
return await this.request(query, variables, { transportOptions: options });
|
|
3078
|
+
}
|
|
3079
|
+
/**
|
|
3080
|
+
* `customPage` paginates a caller-authored `Page` document through the
|
|
3081
|
+
* shared engine. Use it for collections whose field combination the
|
|
3082
|
+
* generated page operations do not expose.
|
|
3083
|
+
*
|
|
3084
|
+
* The document must declare the standard AniList `Page` wrapper with
|
|
3085
|
+
* `$page`/`$perPage` `Int` variables and select `pageInfo { hasNextPage }`
|
|
3086
|
+
* (the engine's terminal flag) plus an items array under a key of your
|
|
3087
|
+
* choosing. Because `Page` is the document's single root field, the
|
|
3088
|
+
* response unwraps to the bare `Page` object, so `TPage` is the `Page`
|
|
3089
|
+
* selection's shape, not an envelope:
|
|
3090
|
+
*
|
|
3091
|
+
* ```typescript
|
|
3092
|
+
* const result = await aniLink.anilist.customPage(
|
|
3093
|
+
* `query ($page: Int, $perPage: Int) {
|
|
3094
|
+
* Page(page: $page, perPage: $perPage) {
|
|
3095
|
+
* pageInfo { total currentPage lastPage hasNextPage }
|
|
3096
|
+
* media(type: ANIME, sort: POPULARITY_DESC) { id title { romaji } }
|
|
3097
|
+
* }
|
|
3098
|
+
* }`,
|
|
3099
|
+
* "media",
|
|
3100
|
+
* {},
|
|
3101
|
+
* { perPage: 50, maxPages: 5 }
|
|
3102
|
+
* );
|
|
3103
|
+
* ```
|
|
3104
|
+
*
|
|
3105
|
+
* The traversal reuses every engine guard: `perPage` clamping (AniList caps
|
|
3106
|
+
* it at 50), the `maxPages` bound (default 100), look-ahead `concurrency`
|
|
3107
|
+
* (default 3), `AbortSignal` forwarding, and the `pageInfo.lastPage`
|
|
3108
|
+
* terminal bound. `variables` are forwarded verbatim on every page
|
|
3109
|
+
* request with `page`/`perPage` merged over them.
|
|
3110
|
+
*
|
|
3111
|
+
* @typeParam TPage - The `Page` selection's response shape; must include `pageInfo` (the engine reads `pageInfo.hasNextPage`).
|
|
3112
|
+
* @typeParam K - The key of the items array on `TPage` (e.g. `"media"`).
|
|
3113
|
+
* @param query - The GraphQL document to traverse. It must be a `query` document whose single root field is `Page`, selected with `$page`/`$perPage` variables; mutation documents, non-`Page` documents, and documents with additional root fields are rejected locally.
|
|
3114
|
+
* @param itemsKey - The key of the items array on the `Page` response (e.g. `"media"`, `"characters"`).
|
|
3115
|
+
* @param variables - The variables for the document, forwarded on every page request with `page`/`perPage` merged over them. This parameter is optional.
|
|
3116
|
+
* @param options - Optional `CustomPageOptions`: the `PaginateOptions` controls (`perPage`, `startPage`, `maxPages`, `concurrency`, `signal`, `onPage`, `onHookError`, `diagnostics`) plus per-request transport settings under `transportOptions`, merged over the instance-level ones for this call only.
|
|
3117
|
+
* @returns The collected items, per-page snapshots, page count, and whether the `maxPages` guard or the server-reported `lastPage` bound truncated the run; a `PaginateResult`.
|
|
3118
|
+
* @throws An {@link AniLinkValidationError} when the document is not a `query` selecting the `Page` root field with `$page`/`$perPage` variable references, or when a fetched page response has no `itemsKey` key at all.
|
|
3119
|
+
* @throws An `AniLinkError` when a page request fails.
|
|
3120
|
+
* @see https://docs.anilist.co/reference/object/page
|
|
3121
|
+
* @example
|
|
3122
|
+
* ```typescript
|
|
3123
|
+
* const result = await aniLink.anilist.customPage(
|
|
3124
|
+
* `query ($page: Int, $perPage: Int) {
|
|
3125
|
+
* Page(page: $page, perPage: $perPage) {
|
|
3126
|
+
* pageInfo { hasNextPage }
|
|
3127
|
+
* characters(search: "spike") { id name { full } }
|
|
3128
|
+
* }
|
|
3129
|
+
* }`,
|
|
3130
|
+
* "characters",
|
|
3131
|
+
* {},
|
|
3132
|
+
* { perPage: 50, maxPages: 3 }
|
|
3133
|
+
* );
|
|
3134
|
+
* console.log(result.items.length, result.truncated);
|
|
3135
|
+
* ```
|
|
3136
|
+
*/
|
|
3137
|
+
async customPage(query, itemsKey, variables = {}, options) {
|
|
3138
|
+
if (typeof query !== "string") {
|
|
3139
|
+
throw new AniLinkValidationError([CUSTOM_PAGE_VALIDATION_MESSAGE]);
|
|
3140
|
+
}
|
|
3141
|
+
const normalized = stripLeadingComments(query);
|
|
3142
|
+
if (!GRAPHQL_OPERATION_PATTERN.test(normalized) || !GRAPHQL_PAGE_QUERY_PATTERN.test(normalized) || extractQueryRootField({ query }) !== "Page" || !/\$page\b/.test(normalized) || !/\$perPage\b/.test(normalized)) {
|
|
3143
|
+
throw new AniLinkValidationError([CUSTOM_PAGE_VALIDATION_MESSAGE]);
|
|
3144
|
+
}
|
|
3145
|
+
const { transportOptions, ...paginateOptions } = options ?? {};
|
|
3146
|
+
return await paginate(
|
|
3147
|
+
(page, perPage, signal) => this.request(
|
|
3148
|
+
query,
|
|
3149
|
+
{ ...variables, page, perPage },
|
|
3150
|
+
{ transportOptions: { ...transportOptions, signal } }
|
|
3151
|
+
),
|
|
3152
|
+
itemsKey,
|
|
3153
|
+
paginateOptions
|
|
3154
|
+
);
|
|
3155
|
+
}
|
|
3156
|
+
}
|
|
3157
|
+
|
|
3158
|
+
function fuzzyDate(options) {
|
|
3159
|
+
const input = {
|
|
3160
|
+
year: options?.year ?? 0,
|
|
3161
|
+
month: options?.month ?? 0,
|
|
3162
|
+
day: options?.day ?? 0
|
|
3163
|
+
};
|
|
3164
|
+
return input;
|
|
3165
|
+
}
|
|
3166
|
+
|
|
3167
|
+
function fuzzyDateInt(options) {
|
|
3168
|
+
const year = options?.year ?? 0;
|
|
3169
|
+
const month = options?.month ?? 0;
|
|
3170
|
+
const day = options?.day ?? 0;
|
|
3171
|
+
return year * 1e4 + month * 100 + day;
|
|
3172
|
+
}
|
|
3173
|
+
|
|
3174
|
+
function flattenMediaListCollection(response) {
|
|
3175
|
+
if (!response || !Array.isArray(response.lists)) {
|
|
3176
|
+
return [];
|
|
3177
|
+
}
|
|
3178
|
+
const byId = /* @__PURE__ */ new Map();
|
|
3179
|
+
for (const group of response.lists) {
|
|
3180
|
+
if (!Array.isArray(group.entries)) {
|
|
3181
|
+
continue;
|
|
3182
|
+
}
|
|
3183
|
+
for (const entry of group.entries) {
|
|
3184
|
+
mergeEntry(byId, entry, group.name, group.isCustomList, group.isSplitCompletedList);
|
|
3185
|
+
}
|
|
3186
|
+
}
|
|
3187
|
+
return Array.from(byId.values());
|
|
3188
|
+
}
|
|
3189
|
+
function mergeEntry(byId, entry, listName, isCustomList, isSplitCompletedList) {
|
|
3190
|
+
if (typeof entry !== "object" || entry === null || typeof entry.id !== "number" || !Number.isFinite(entry.id)) {
|
|
3191
|
+
throw new AniLinkValidationError([
|
|
3192
|
+
`flattenMediaListCollection: a list entry in "${listName}" is malformed \u2014 every entry must be an object with a finite numeric id.`
|
|
3193
|
+
]);
|
|
3194
|
+
}
|
|
3195
|
+
const existing = byId.get(entry.id);
|
|
3196
|
+
if (existing) {
|
|
3197
|
+
if (!existing.listNames.includes(listName)) {
|
|
3198
|
+
existing.listNames.push(listName);
|
|
3199
|
+
}
|
|
3200
|
+
if (isCustomList) existing.inCustomList = true;
|
|
3201
|
+
if (isSplitCompletedList) existing.inSplitCompletedList = true;
|
|
3202
|
+
return;
|
|
3203
|
+
}
|
|
3204
|
+
byId.set(entry.id, {
|
|
3205
|
+
id: entry.id,
|
|
3206
|
+
userId: entry.userId,
|
|
3207
|
+
mediaId: entry.mediaId,
|
|
3208
|
+
status: entry.status,
|
|
3209
|
+
score: entry.score,
|
|
3210
|
+
progress: entry.progress,
|
|
3211
|
+
listNames: [listName],
|
|
3212
|
+
inCustomList: isCustomList,
|
|
3213
|
+
inSplitCompletedList: isSplitCompletedList
|
|
3214
|
+
});
|
|
3215
|
+
}
|
|
3216
|
+
|
|
3217
|
+
function crossLink(media) {
|
|
3218
|
+
const anilistToMal = /* @__PURE__ */ new Map();
|
|
3219
|
+
const malToAnilist = /* @__PURE__ */ new Map();
|
|
3220
|
+
const unmapped = [];
|
|
3221
|
+
for (const entry of media) {
|
|
3222
|
+
if (typeof entry.idMal !== "number") {
|
|
3223
|
+
unmapped.push(entry);
|
|
3224
|
+
continue;
|
|
3225
|
+
}
|
|
3226
|
+
anilistToMal.set(entry.id, entry.idMal);
|
|
3227
|
+
malToAnilist.set(entry.idMal, entry.id);
|
|
3228
|
+
}
|
|
3229
|
+
return { anilistToMal, malToAnilist, unmapped };
|
|
3230
|
+
}
|
|
3231
|
+
|
|
3232
|
+
const ARM_IDS_URL = "https://arm.haglund.dev/api/v2/ids?include=anilist,myanimelist";
|
|
3233
|
+
const ARM_MAX_IDS_PER_REQUEST = 100;
|
|
3234
|
+
async function mapExternalIds(source, ids, options = {}) {
|
|
3235
|
+
return requestMapExternalIds(source, ids, options, options);
|
|
3236
|
+
}
|
|
3237
|
+
const buildMapExternalIdsFacade = (instanceOptions, stateOwner) => (source, ids, options) => requestMapExternalIds(source, ids, mergeOptions(instanceOptions, options), stateOwner);
|
|
3238
|
+
const requestMapExternalIds = async (source, ids, options, stateOwner) => {
|
|
3239
|
+
const anilistToMal = /* @__PURE__ */ new Map();
|
|
3240
|
+
const malToAnilist = /* @__PURE__ */ new Map();
|
|
3241
|
+
const unmapped = [];
|
|
3242
|
+
if (ids.length === 0) {
|
|
3243
|
+
return { anilistToMal, malToAnilist, unmapped };
|
|
3244
|
+
}
|
|
3245
|
+
for (let offset = 0; offset < ids.length; offset += ARM_MAX_IDS_PER_REQUEST) {
|
|
3246
|
+
const batchIds = ids.slice(offset, offset + ARM_MAX_IDS_PER_REQUEST);
|
|
3247
|
+
const responseBody = await sendRequest(
|
|
3248
|
+
ARM_IDS_URL,
|
|
3249
|
+
"POST",
|
|
3250
|
+
batchIds.map((id) => ({ [source]: id })),
|
|
3251
|
+
void 0,
|
|
3252
|
+
{
|
|
3253
|
+
options,
|
|
3254
|
+
protocol: "rest",
|
|
3255
|
+
stateOwner
|
|
3256
|
+
}
|
|
3257
|
+
);
|
|
3258
|
+
if (!Array.isArray(responseBody) || responseBody.length !== batchIds.length) {
|
|
3259
|
+
throw new TypeError("ARM ID mapping response did not contain one result per input id.");
|
|
3260
|
+
}
|
|
3261
|
+
for (let index = 0; index < batchIds.length; index += 1) {
|
|
3262
|
+
const relation = responseBody[index];
|
|
3263
|
+
const inputId = batchIds[index];
|
|
3264
|
+
const mappedId = source === "anilist" ? relation?.myanimelist : relation?.anilist;
|
|
3265
|
+
if (typeof mappedId !== "number") {
|
|
3266
|
+
unmapped.push(inputId);
|
|
3267
|
+
continue;
|
|
3268
|
+
}
|
|
3269
|
+
if (source === "anilist") {
|
|
3270
|
+
anilistToMal.set(inputId, mappedId);
|
|
3271
|
+
malToAnilist.set(mappedId, inputId);
|
|
3272
|
+
} else {
|
|
3273
|
+
anilistToMal.set(mappedId, inputId);
|
|
3274
|
+
malToAnilist.set(inputId, mappedId);
|
|
2696
3275
|
}
|
|
2697
|
-
const pageItems = Array.isArray(raw) ? raw : [];
|
|
2698
|
-
pages.push({ pageInfo: response.pageInfo, items: pageItems });
|
|
2699
|
-
items.push(...pageItems);
|
|
2700
|
-
safeCallback$1(options?.onPage, "onPage", options?.onHookError, diagnostics, {
|
|
2701
|
-
pageInfo: response.pageInfo,
|
|
2702
|
-
items: pageItems
|
|
2703
|
-
});
|
|
2704
3276
|
}
|
|
2705
|
-
return { items, pages, pageCount: count, truncated };
|
|
2706
|
-
} finally {
|
|
2707
|
-
dispose();
|
|
2708
3277
|
}
|
|
3278
|
+
return { anilistToMal, malToAnilist, unmapped };
|
|
3279
|
+
};
|
|
3280
|
+
|
|
3281
|
+
const DEFAULT_INTERVAL_MS = 6e4;
|
|
3282
|
+
const MIN_INTERVAL_MS = 1e4;
|
|
3283
|
+
const DEFAULT_PER_PAGE$1 = 50;
|
|
3284
|
+
const MAX_PER_PAGE$1 = DEFAULT_PER_PAGE$1;
|
|
3285
|
+
const MAX_DRAIN_PAGES = 10;
|
|
3286
|
+
const SEEN_RETENTION_POLLS = 3;
|
|
3287
|
+
const sleepBetweenPolls = (ms, signal) => new Promise((resolve) => {
|
|
3288
|
+
const timeout = setTimeout(() => {
|
|
3289
|
+
signal?.removeEventListener("abort", abort);
|
|
3290
|
+
resolve();
|
|
3291
|
+
}, ms);
|
|
3292
|
+
timeout.unref();
|
|
3293
|
+
const abort = () => {
|
|
3294
|
+
clearTimeout(timeout);
|
|
3295
|
+
resolve();
|
|
3296
|
+
};
|
|
3297
|
+
if (signal?.aborted) {
|
|
3298
|
+
clearTimeout(timeout);
|
|
3299
|
+
resolve();
|
|
3300
|
+
return;
|
|
3301
|
+
}
|
|
3302
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
3303
|
+
});
|
|
3304
|
+
const dropUndefined = (variables) => {
|
|
3305
|
+
const result = {};
|
|
3306
|
+
for (const [key, value] of Object.entries(variables)) {
|
|
3307
|
+
if (value !== void 0) {
|
|
3308
|
+
result[key] = value;
|
|
3309
|
+
}
|
|
3310
|
+
}
|
|
3311
|
+
return result;
|
|
3312
|
+
};
|
|
3313
|
+
async function drainPoll(ctx) {
|
|
3314
|
+
const { fetchPage, perPage, since, signal, seen } = ctx;
|
|
3315
|
+
const fresh = [];
|
|
3316
|
+
const startPage = ctx.resumeFromPage;
|
|
3317
|
+
const resuming = startPage > 1;
|
|
3318
|
+
const baselineDrain = ctx.baselinePending;
|
|
3319
|
+
ctx.resumeFromPage = 1;
|
|
3320
|
+
let hitSeenFrontier = false;
|
|
3321
|
+
let drainedToCap = false;
|
|
3322
|
+
let page = startPage;
|
|
3323
|
+
let lastDrainedItems = [];
|
|
3324
|
+
let lastDrainedHasNext = false;
|
|
3325
|
+
for (; page < startPage + MAX_DRAIN_PAGES; page++) {
|
|
3326
|
+
const { pageInfo, items } = await fetchPage(page, perPage, signal);
|
|
3327
|
+
lastDrainedItems = items;
|
|
3328
|
+
lastDrainedHasNext = pageInfo.hasNextPage;
|
|
3329
|
+
for (const item of items) {
|
|
3330
|
+
if (seen.has(item.id) || since !== void 0 && item.createdAt <= since) {
|
|
3331
|
+
hitSeenFrontier = true;
|
|
3332
|
+
continue;
|
|
3333
|
+
}
|
|
3334
|
+
if (!ctx.baselinePending) {
|
|
3335
|
+
fresh.push(item);
|
|
3336
|
+
}
|
|
3337
|
+
}
|
|
3338
|
+
for (const item of items) {
|
|
3339
|
+
seen.set(item.id, item.createdAt);
|
|
3340
|
+
}
|
|
3341
|
+
if (hitSeenFrontier || items.length < perPage) break;
|
|
3342
|
+
if (!pageInfo.hasNextPage) break;
|
|
3343
|
+
if (page === startPage + MAX_DRAIN_PAGES - 1) {
|
|
3344
|
+
drainedToCap = true;
|
|
3345
|
+
}
|
|
3346
|
+
}
|
|
3347
|
+
if (drainedToCap) {
|
|
3348
|
+
ctx.resumeFromPage = baselineDrain ? startPage : page;
|
|
3349
|
+
} else if (resuming && hitSeenFrontier && page === startPage && lastDrainedHasNext) {
|
|
3350
|
+
ctx.resumeFromPage = startPage + 1;
|
|
3351
|
+
}
|
|
3352
|
+
ctx.baselinePending = false;
|
|
3353
|
+
const oldestDrained = lastDrainedItems[lastDrainedItems.length - 1];
|
|
3354
|
+
return {
|
|
3355
|
+
fresh,
|
|
3356
|
+
resumeFromPage: ctx.resumeFromPage,
|
|
3357
|
+
watermark: oldestDrained?.createdAt
|
|
3358
|
+
};
|
|
2709
3359
|
}
|
|
2710
|
-
async function*
|
|
2711
|
-
const
|
|
2712
|
-
|
|
2713
|
-
|
|
2714
|
-
const concurrency = resolveCappedInt(
|
|
2715
|
-
options?.concurrency,
|
|
2716
|
-
MAX_CONCURRENCY,
|
|
2717
|
-
DEFAULT_CONCURRENCY$1,
|
|
2718
|
-
"concurrency"
|
|
3360
|
+
async function* watchFeed(fetchPage, options) {
|
|
3361
|
+
const intervalMs = Math.max(
|
|
3362
|
+
resolvePositiveInt(options.intervalMs, DEFAULT_INTERVAL_MS, "intervalMs"),
|
|
3363
|
+
MIN_INTERVAL_MS
|
|
2719
3364
|
);
|
|
2720
|
-
|
|
3365
|
+
const perPage = resolveCappedInt(options.perPage, MAX_PER_PAGE$1, DEFAULT_PER_PAGE$1, "perPage");
|
|
3366
|
+
const { since, signal } = options;
|
|
3367
|
+
const ctx = {
|
|
2721
3368
|
fetchPage,
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
|
|
2726
|
-
|
|
2727
|
-
|
|
2728
|
-
|
|
2729
|
-
|
|
2730
|
-
|
|
2731
|
-
|
|
2732
|
-
|
|
2733
|
-
|
|
2734
|
-
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
|
|
2738
|
-
MAX_CONCURRENCY,
|
|
2739
|
-
DEFAULT_CONCURRENCY$1,
|
|
2740
|
-
"concurrency"
|
|
2741
|
-
);
|
|
2742
|
-
const diagnostics = resolveDiagnosticsMode(options?.diagnostics);
|
|
2743
|
-
const { signal, dispose } = bridgeAbortSignal(options?.signal);
|
|
2744
|
-
try {
|
|
2745
|
-
const { responses, count, truncated } = await fetchWithLookAhead(
|
|
2746
|
-
(number) => fetchChunk(number, perChunk, signal),
|
|
2747
|
-
extractHasMore$1,
|
|
2748
|
-
startChunk,
|
|
2749
|
-
maxChunks,
|
|
2750
|
-
concurrency,
|
|
2751
|
-
signal
|
|
2752
|
-
);
|
|
2753
|
-
const items = [];
|
|
2754
|
-
const chunks = [];
|
|
2755
|
-
for (const response of responses) {
|
|
2756
|
-
const raw = response[itemsKey];
|
|
2757
|
-
if (raw === void 0) {
|
|
2758
|
-
throw new AniLinkValidationError([
|
|
2759
|
-
`paginateChunks: the chunk response has no "${itemsKey}" key. Check the itemsKey argument.`
|
|
2760
|
-
]);
|
|
3369
|
+
perPage,
|
|
3370
|
+
since,
|
|
3371
|
+
signal,
|
|
3372
|
+
seen: /* @__PURE__ */ new Map(),
|
|
3373
|
+
baselinePending: since === void 0,
|
|
3374
|
+
resumeFromPage: 1
|
|
3375
|
+
};
|
|
3376
|
+
while (!signal?.aborted) {
|
|
3377
|
+
const { fresh, resumeFromPage, watermark } = await drainPoll(ctx);
|
|
3378
|
+
ctx.resumeFromPage = resumeFromPage;
|
|
3379
|
+
if (watermark !== void 0) {
|
|
3380
|
+
const pruneBefore = watermark - intervalMs / 1e3 * SEEN_RETENTION_POLLS;
|
|
3381
|
+
for (const [id, createdAt] of ctx.seen) {
|
|
3382
|
+
if (createdAt < pruneBefore) {
|
|
3383
|
+
ctx.seen.delete(id);
|
|
3384
|
+
}
|
|
2761
3385
|
}
|
|
2762
|
-
const chunkItems = Array.isArray(raw) ? raw : [];
|
|
2763
|
-
chunks.push({ hasNextChunk: response.hasNextChunk, items: chunkItems });
|
|
2764
|
-
items.push(...chunkItems);
|
|
2765
|
-
safeCallback$1(options?.onChunk, "onChunk", options?.onHookError, diagnostics, {
|
|
2766
|
-
hasNextChunk: response.hasNextChunk,
|
|
2767
|
-
items: chunkItems
|
|
2768
|
-
});
|
|
2769
3386
|
}
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
3387
|
+
for (let index = fresh.length - 1; index >= 0; index--) {
|
|
3388
|
+
yield fresh[index];
|
|
3389
|
+
}
|
|
3390
|
+
await sleepBetweenPolls(intervalMs, signal);
|
|
3391
|
+
}
|
|
3392
|
+
}
|
|
3393
|
+
function watchNotifications(fetch, options = {}) {
|
|
3394
|
+
const { type, type_in, resetNotificationCount, asHtml, ...shared } = options;
|
|
3395
|
+
let resetPending = resetNotificationCount === true;
|
|
3396
|
+
return watchFeed(async (page, perPage, signal) => {
|
|
3397
|
+
const resetNow = resetPending;
|
|
3398
|
+
const response = await fetch(
|
|
3399
|
+
dropUndefined({
|
|
3400
|
+
page,
|
|
3401
|
+
perPage,
|
|
3402
|
+
type,
|
|
3403
|
+
type_in,
|
|
3404
|
+
resetNotificationCount: resetNow,
|
|
3405
|
+
asHtml
|
|
3406
|
+
}),
|
|
3407
|
+
// Poll requests always bypass the response cache: each poll
|
|
3408
|
+
// needs current data and must never be served a cached page,
|
|
3409
|
+
// so a cache enabled on the client cannot make the watcher
|
|
3410
|
+
// stale.
|
|
3411
|
+
{ ...shared.transportOptions, signal, bypassResponseCache: true }
|
|
3412
|
+
);
|
|
3413
|
+
resetPending = false;
|
|
3414
|
+
return { pageInfo: response.pageInfo, items: response.notifications };
|
|
3415
|
+
}, shared);
|
|
3416
|
+
}
|
|
3417
|
+
function watchActivity(fetch, options = {}) {
|
|
3418
|
+
const { userId, messengerId, mediaId, type, isFollowing, sort, ...shared } = options;
|
|
3419
|
+
return watchFeed(async (page, perPage, signal) => {
|
|
3420
|
+
const response = await fetch(
|
|
3421
|
+
// ID_DESC (newest first) is the order the watcher needs:
|
|
3422
|
+
// new items enter at the front, so page 1 is always the
|
|
3423
|
+
// window that contains them. A caller-provided sort
|
|
3424
|
+
// overrides it; see the option's caveat.
|
|
3425
|
+
dropUndefined({
|
|
3426
|
+
page,
|
|
3427
|
+
perPage,
|
|
3428
|
+
userId,
|
|
3429
|
+
messengerId,
|
|
3430
|
+
mediaId,
|
|
3431
|
+
type,
|
|
3432
|
+
isFollowing,
|
|
3433
|
+
sort: sort ?? ["ID_DESC"]
|
|
3434
|
+
}),
|
|
3435
|
+
// Poll requests always bypass the response cache: each poll
|
|
3436
|
+
// needs current data and must never be served a cached page,
|
|
3437
|
+
// so a cache enabled on the client cannot make the watcher
|
|
3438
|
+
// stale.
|
|
3439
|
+
{ ...shared.transportOptions, signal, bypassResponseCache: true }
|
|
3440
|
+
);
|
|
3441
|
+
return { pageInfo: response.pageInfo, items: response.activities };
|
|
3442
|
+
}, shared);
|
|
2774
3443
|
}
|
|
2775
3444
|
|
|
2776
3445
|
const parseCache = /* @__PURE__ */ new Map();
|
|
@@ -6077,6 +6746,7 @@ class NotificationsQuery extends AniListOperation {
|
|
|
6077
6746
|
variables,
|
|
6078
6747
|
{
|
|
6079
6748
|
mappings: NotificationsMappings,
|
|
6749
|
+
requiresAuth: true,
|
|
6080
6750
|
transportOptions
|
|
6081
6751
|
}
|
|
6082
6752
|
);
|
|
@@ -8226,12 +8896,13 @@ class SaveMediaListEntryMutation extends AniListOperation {
|
|
|
8226
8896
|
}
|
|
8227
8897
|
}
|
|
8228
8898
|
|
|
8229
|
-
function op(name, operationClass, options) {
|
|
8899
|
+
function op$1(name, operationClass, options) {
|
|
8230
8900
|
return {
|
|
8231
8901
|
name,
|
|
8232
8902
|
operationClass,
|
|
8233
8903
|
methodName: name,
|
|
8234
|
-
fieldsEnabled: options?.fieldsEnabled ?? false
|
|
8904
|
+
fieldsEnabled: options?.fieldsEnabled ?? false,
|
|
8905
|
+
alwaysKeys: options?.alwaysKeys ?? []
|
|
8235
8906
|
};
|
|
8236
8907
|
}
|
|
8237
8908
|
function opAs(name, operationClass, methodName, options) {
|
|
@@ -8239,96 +8910,224 @@ function opAs(name, operationClass, methodName, options) {
|
|
|
8239
8910
|
name,
|
|
8240
8911
|
operationClass,
|
|
8241
8912
|
methodName,
|
|
8242
|
-
fieldsEnabled: options?.fieldsEnabled ?? false
|
|
8913
|
+
fieldsEnabled: options?.fieldsEnabled ?? false,
|
|
8914
|
+
alwaysKeys: options?.alwaysKeys ?? []
|
|
8243
8915
|
};
|
|
8244
8916
|
}
|
|
8245
8917
|
const ANILIST_OPERATION_REGISTRY = {
|
|
8246
8918
|
query: [
|
|
8247
|
-
op("user", UserQuery, { fieldsEnabled: true }),
|
|
8248
|
-
op("media", MediaQuery, { fieldsEnabled: true }),
|
|
8249
|
-
op("mediaTrend", MediaTrendQuery, { fieldsEnabled: true }),
|
|
8250
|
-
op("airingSchedule", AiringScheduleQuery, {
|
|
8251
|
-
|
|
8252
|
-
|
|
8253
|
-
|
|
8254
|
-
op("
|
|
8255
|
-
op("
|
|
8256
|
-
op("
|
|
8257
|
-
op("
|
|
8258
|
-
|
|
8259
|
-
|
|
8260
|
-
|
|
8261
|
-
op("
|
|
8262
|
-
op("
|
|
8263
|
-
op("
|
|
8264
|
-
op("
|
|
8265
|
-
op("
|
|
8266
|
-
op("
|
|
8267
|
-
op("
|
|
8268
|
-
op("
|
|
8269
|
-
op("
|
|
8270
|
-
op("
|
|
8271
|
-
op("
|
|
8919
|
+
op$1("user", UserQuery, { fieldsEnabled: true, alwaysKeys: USER_ALWAYS }),
|
|
8920
|
+
op$1("media", MediaQuery, { fieldsEnabled: true, alwaysKeys: MEDIA_ALWAYS }),
|
|
8921
|
+
op$1("mediaTrend", MediaTrendQuery, { fieldsEnabled: true, alwaysKeys: MEDIA_TREND_ALWAYS }),
|
|
8922
|
+
op$1("airingSchedule", AiringScheduleQuery, {
|
|
8923
|
+
fieldsEnabled: true,
|
|
8924
|
+
alwaysKeys: AIRING_SCHEDULE_ALWAYS
|
|
8925
|
+
}),
|
|
8926
|
+
op$1("character", CharacterQuery, { fieldsEnabled: true, alwaysKeys: CHARACTER_ALWAYS }),
|
|
8927
|
+
op$1("staff", StaffQuery, { fieldsEnabled: true, alwaysKeys: STAFF_ALWAYS }),
|
|
8928
|
+
op$1("mediaList", MediaListQuery, { fieldsEnabled: true, alwaysKeys: MEDIA_LIST_ALWAYS }),
|
|
8929
|
+
op$1("mediaListCollection", MediaListCollectionQuery, {
|
|
8930
|
+
fieldsEnabled: true,
|
|
8931
|
+
alwaysKeys: MEDIA_LIST_COLLECTION_ALWAYS
|
|
8932
|
+
}),
|
|
8933
|
+
op$1("genreCollection", GenreCollectionQuery),
|
|
8934
|
+
op$1("mediaTagCollection", MediaTagCollectionQuery),
|
|
8935
|
+
op$1("viewer", ViewerQuery),
|
|
8936
|
+
op$1("notification", NotificationQuery, { fieldsEnabled: true }),
|
|
8937
|
+
op$1("studio", StudioQuery, { fieldsEnabled: true, alwaysKeys: STUDIO_ALWAYS }),
|
|
8938
|
+
op$1("review", ReviewQuery, { fieldsEnabled: true, alwaysKeys: REVIEW_ALWAYS }),
|
|
8939
|
+
op$1("activity", ActivityQuery, { fieldsEnabled: true }),
|
|
8940
|
+
op$1("activityReply", ActivityReplyQuery),
|
|
8941
|
+
op$1("following", FollowingQuery),
|
|
8942
|
+
op$1("follower", FollowerQuery),
|
|
8943
|
+
op$1("thread", ThreadQuery, { fieldsEnabled: true, alwaysKeys: THREAD_ALWAYS }),
|
|
8944
|
+
op$1("threadComment", ThreadCommentQuery, {
|
|
8945
|
+
fieldsEnabled: true,
|
|
8946
|
+
alwaysKeys: THREAD_COMMENT_ALWAYS
|
|
8947
|
+
}),
|
|
8948
|
+
op$1("recommendation", RecommendationQuery, {
|
|
8949
|
+
fieldsEnabled: true,
|
|
8950
|
+
alwaysKeys: RECOMMENDATION_ALWAYS
|
|
8951
|
+
}),
|
|
8952
|
+
op$1("markdown", MarkdownQuery),
|
|
8953
|
+
op$1("aniChartUser", AniChartUserQuery),
|
|
8954
|
+
op$1("siteStatistics", SiteStatisticsQuery, {
|
|
8955
|
+
fieldsEnabled: true,
|
|
8956
|
+
alwaysKeys: SITE_STATISTICS_ALWAYS
|
|
8957
|
+
}),
|
|
8958
|
+
op$1("externalLinkSourceCollection", ExternalLinkSourceCollectionQuery)
|
|
8272
8959
|
],
|
|
8273
8960
|
page: [
|
|
8274
|
-
op("users", UsersQuery, { fieldsEnabled: true }),
|
|
8275
|
-
op("medias", MediasQuery, { fieldsEnabled: true }),
|
|
8276
|
-
op("characters", CharactersQuery, { fieldsEnabled: true }),
|
|
8277
|
-
op("staffs", StaffsQuery, { fieldsEnabled: true }),
|
|
8278
|
-
op("studios", StudiosQuery, { fieldsEnabled: true }),
|
|
8279
|
-
op("mediaLists", MediaListsQuery, { fieldsEnabled: true }),
|
|
8280
|
-
op("airingSchedules", AiringSchedulesQuery, {
|
|
8281
|
-
|
|
8282
|
-
|
|
8283
|
-
|
|
8284
|
-
|
|
8285
|
-
op("
|
|
8286
|
-
|
|
8287
|
-
|
|
8288
|
-
|
|
8289
|
-
|
|
8290
|
-
|
|
8291
|
-
op("
|
|
8961
|
+
op$1("users", UsersQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8962
|
+
op$1("medias", MediasQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8963
|
+
op$1("characters", CharactersQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8964
|
+
op$1("staffs", StaffsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8965
|
+
op$1("studios", StudiosQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8966
|
+
op$1("mediaLists", MediaListsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8967
|
+
op$1("airingSchedules", AiringSchedulesQuery, {
|
|
8968
|
+
fieldsEnabled: true,
|
|
8969
|
+
alwaysKeys: PAGE_ALWAYS
|
|
8970
|
+
}),
|
|
8971
|
+
op$1("mediaTrends", MediaTrendsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8972
|
+
op$1("notifications", NotificationsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8973
|
+
op$1("followers", FollowersQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8974
|
+
opAs("following", FollowingsQuery, "followings", {
|
|
8975
|
+
fieldsEnabled: true,
|
|
8976
|
+
alwaysKeys: PAGE_ALWAYS
|
|
8977
|
+
}),
|
|
8978
|
+
op$1("activities", ActivitiesQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8979
|
+
opAs("activityReplies", ActivityRepliesQuery, "activityReplies", {
|
|
8980
|
+
fieldsEnabled: true,
|
|
8981
|
+
alwaysKeys: PAGE_ALWAYS
|
|
8982
|
+
}),
|
|
8983
|
+
op$1("threads", ThreadsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8984
|
+
opAs("threadComments", ThreadCommentsQuery, "threadComments", {
|
|
8985
|
+
fieldsEnabled: true,
|
|
8986
|
+
alwaysKeys: PAGE_ALWAYS
|
|
8987
|
+
}),
|
|
8988
|
+
op$1("reviews", ReviewsQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS }),
|
|
8989
|
+
opAs("recommendations", RecommendationsQuery, "recommendations", {
|
|
8990
|
+
fieldsEnabled: true,
|
|
8991
|
+
alwaysKeys: PAGE_ALWAYS
|
|
8992
|
+
}),
|
|
8993
|
+
op$1("likes", LikesQuery, { fieldsEnabled: true, alwaysKeys: PAGE_ALWAYS })
|
|
8292
8994
|
],
|
|
8293
8995
|
mutation: [
|
|
8294
|
-
op("updateUser", UpdateUserMutation, { fieldsEnabled: true }),
|
|
8295
|
-
op("saveMediaListEntry", SaveMediaListEntryMutation, { fieldsEnabled: true }),
|
|
8296
|
-
op("updateMediaListEntries", UpdateMediaListEntriesMutation, { fieldsEnabled: true }),
|
|
8297
|
-
op("deleteMediaListEntry", DeleteMediaListEntryMutation, { fieldsEnabled: true }),
|
|
8298
|
-
op("deleteCustomList", DeleteCustomListMutation, { fieldsEnabled: true }),
|
|
8299
|
-
op("saveTextActivity", SaveTextActivityMutation, { fieldsEnabled: true }),
|
|
8300
|
-
op("saveMessageActivity", SaveMessageActivityMutation, { fieldsEnabled: true }),
|
|
8301
|
-
op("saveListActivity", SaveListActivityMutation, { fieldsEnabled: true }),
|
|
8302
|
-
op("deleteActivity", DeleteActivityMutation, { fieldsEnabled: true }),
|
|
8303
|
-
op("toggleActivityPin", ToggleActivityPinMutation, { fieldsEnabled: true }),
|
|
8304
|
-
op("toggleActivitySubscription", ToggleActivitySubscriptionMutation, {
|
|
8996
|
+
op$1("updateUser", UpdateUserMutation, { fieldsEnabled: true }),
|
|
8997
|
+
op$1("saveMediaListEntry", SaveMediaListEntryMutation, { fieldsEnabled: true }),
|
|
8998
|
+
op$1("updateMediaListEntries", UpdateMediaListEntriesMutation, { fieldsEnabled: true }),
|
|
8999
|
+
op$1("deleteMediaListEntry", DeleteMediaListEntryMutation, { fieldsEnabled: true }),
|
|
9000
|
+
op$1("deleteCustomList", DeleteCustomListMutation, { fieldsEnabled: true }),
|
|
9001
|
+
op$1("saveTextActivity", SaveTextActivityMutation, { fieldsEnabled: true }),
|
|
9002
|
+
op$1("saveMessageActivity", SaveMessageActivityMutation, { fieldsEnabled: true }),
|
|
9003
|
+
op$1("saveListActivity", SaveListActivityMutation, { fieldsEnabled: true }),
|
|
9004
|
+
op$1("deleteActivity", DeleteActivityMutation, { fieldsEnabled: true }),
|
|
9005
|
+
op$1("toggleActivityPin", ToggleActivityPinMutation, { fieldsEnabled: true }),
|
|
9006
|
+
op$1("toggleActivitySubscription", ToggleActivitySubscriptionMutation, {
|
|
8305
9007
|
fieldsEnabled: true
|
|
8306
9008
|
}),
|
|
8307
|
-
op("saveActivityReply", SaveActivityReplyMutation, { fieldsEnabled: true }),
|
|
8308
|
-
op("deleteActivityReply", DeleteActivityReplyMutation, { fieldsEnabled: true }),
|
|
8309
|
-
op("toggleLike", ToggleLikeMutation, { fieldsEnabled: true }),
|
|
8310
|
-
op("toggleLikeV2", ToggleLikeV2Mutation, { fieldsEnabled: true }),
|
|
8311
|
-
op("toggleFollow", ToggleFollowMutation, { fieldsEnabled: true }),
|
|
8312
|
-
op("toggleFavourite", ToggleFavouriteMutation, { fieldsEnabled: true }),
|
|
8313
|
-
op("updateFavouriteOrder", UpdateFavouriteOrderMutation, { fieldsEnabled: true }),
|
|
8314
|
-
op("saveReview", SaveReviewMutation, { fieldsEnabled: true }),
|
|
8315
|
-
op("rateReview", RateReviewMutation, { fieldsEnabled: true }),
|
|
8316
|
-
op("deleteReview", DeleteReviewMutation, { fieldsEnabled: true }),
|
|
8317
|
-
op("saveRecommendation", SaveRecommendationMutation, { fieldsEnabled: true }),
|
|
8318
|
-
op("saveThread", SaveThreadMutation, { fieldsEnabled: true }),
|
|
8319
|
-
op("deleteThread", DeleteThreadMutation, { fieldsEnabled: true }),
|
|
8320
|
-
op("toggleThreadSubscription", ToggleThreadSubscriptionMutation, { fieldsEnabled: true }),
|
|
8321
|
-
op("saveThreadComment", SaveThreadCommentMutation, { fieldsEnabled: true }),
|
|
8322
|
-
op("deleteThreadComment", DeleteThreadCommentMutation, { fieldsEnabled: true }),
|
|
8323
|
-
op("updateAniChartSettings", UpdateAniChartSettingsMutation),
|
|
8324
|
-
op("updateAniChartHighlights", UpdateAniChartHighlightsMutation)
|
|
9009
|
+
op$1("saveActivityReply", SaveActivityReplyMutation, { fieldsEnabled: true }),
|
|
9010
|
+
op$1("deleteActivityReply", DeleteActivityReplyMutation, { fieldsEnabled: true }),
|
|
9011
|
+
op$1("toggleLike", ToggleLikeMutation, { fieldsEnabled: true }),
|
|
9012
|
+
op$1("toggleLikeV2", ToggleLikeV2Mutation, { fieldsEnabled: true }),
|
|
9013
|
+
op$1("toggleFollow", ToggleFollowMutation, { fieldsEnabled: true }),
|
|
9014
|
+
op$1("toggleFavourite", ToggleFavouriteMutation, { fieldsEnabled: true }),
|
|
9015
|
+
op$1("updateFavouriteOrder", UpdateFavouriteOrderMutation, { fieldsEnabled: true }),
|
|
9016
|
+
op$1("saveReview", SaveReviewMutation, { fieldsEnabled: true }),
|
|
9017
|
+
op$1("rateReview", RateReviewMutation, { fieldsEnabled: true }),
|
|
9018
|
+
op$1("deleteReview", DeleteReviewMutation, { fieldsEnabled: true }),
|
|
9019
|
+
op$1("saveRecommendation", SaveRecommendationMutation, { fieldsEnabled: true }),
|
|
9020
|
+
op$1("saveThread", SaveThreadMutation, { fieldsEnabled: true }),
|
|
9021
|
+
op$1("deleteThread", DeleteThreadMutation, { fieldsEnabled: true }),
|
|
9022
|
+
op$1("toggleThreadSubscription", ToggleThreadSubscriptionMutation, { fieldsEnabled: true }),
|
|
9023
|
+
op$1("saveThreadComment", SaveThreadCommentMutation, { fieldsEnabled: true }),
|
|
9024
|
+
op$1("deleteThreadComment", DeleteThreadCommentMutation, { fieldsEnabled: true }),
|
|
9025
|
+
op$1("updateAniChartSettings", UpdateAniChartSettingsMutation),
|
|
9026
|
+
op$1("updateAniChartHighlights", UpdateAniChartHighlightsMutation)
|
|
8325
9027
|
]
|
|
8326
9028
|
};
|
|
8327
9029
|
|
|
9030
|
+
const sanitizeTokenError = (error, label) => {
|
|
9031
|
+
if (error instanceof AniLinkRestError) {
|
|
9032
|
+
const relabeled = new AniLinkRestError(error.status, error.data, void 0, {
|
|
9033
|
+
rateLimit: error.rateLimit,
|
|
9034
|
+
contentType: error.contentType,
|
|
9035
|
+
requestId: error.requestId
|
|
9036
|
+
});
|
|
9037
|
+
if (error.rawAxiosError === void 0) {
|
|
9038
|
+
relabeled.cause = error;
|
|
9039
|
+
}
|
|
9040
|
+
relabeled.message = `${label} failed with status ${error.status}.`;
|
|
9041
|
+
return relabeled;
|
|
9042
|
+
}
|
|
9043
|
+
if (error instanceof AniLinkApiError) {
|
|
9044
|
+
const relabeled = new AniLinkApiError(error.status, error.data, void 0, {
|
|
9045
|
+
rateLimit: error.rateLimit,
|
|
9046
|
+
contentType: error.contentType,
|
|
9047
|
+
requestId: error.requestId
|
|
9048
|
+
});
|
|
9049
|
+
if (error.rawAxiosError === void 0) {
|
|
9050
|
+
relabeled.cause = error;
|
|
9051
|
+
}
|
|
9052
|
+
relabeled.message = `${label} failed with status ${error.status}.`;
|
|
9053
|
+
return relabeled;
|
|
9054
|
+
}
|
|
9055
|
+
if (error instanceof AniLinkError) {
|
|
9056
|
+
return error;
|
|
9057
|
+
}
|
|
9058
|
+
if (axios.isCancel(error)) {
|
|
9059
|
+
return new AniLinkNetworkError(AniLinkErrorCodes.ABORTED, `${label} was cancelled.`);
|
|
9060
|
+
}
|
|
9061
|
+
if (axios.isAxiosError(error)) {
|
|
9062
|
+
if (error.response?.status !== void 0) {
|
|
9063
|
+
const status = error.response.status;
|
|
9064
|
+
const apiError = new AniLinkApiError(status, error.response.data);
|
|
9065
|
+
apiError.message = `${label} failed with status ${status}.`;
|
|
9066
|
+
return apiError;
|
|
9067
|
+
}
|
|
9068
|
+
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
|
|
9069
|
+
return new AniLinkNetworkError(AniLinkErrorCodes.TIMEOUT, `${label} timed out.`);
|
|
9070
|
+
}
|
|
9071
|
+
return new AniLinkNetworkError(
|
|
9072
|
+
AniLinkErrorCodes.NETWORK,
|
|
9073
|
+
`${label} failed due to a network error.`
|
|
9074
|
+
);
|
|
9075
|
+
}
|
|
9076
|
+
return new AniLinkError(`${label} failed.`, AniLinkErrorCodes.UNKNOWN);
|
|
9077
|
+
};
|
|
9078
|
+
|
|
9079
|
+
const TOKEN_REQUEST_TIMEOUT_MS = 1e4;
|
|
9080
|
+
const requestTokenGrant = async (descriptor, params, signal, options) => {
|
|
9081
|
+
try {
|
|
9082
|
+
const body = new URLSearchParams(params).toString();
|
|
9083
|
+
return await sendRequest(descriptor.tokenUrl, "POST", body, void 0, {
|
|
9084
|
+
requiresAuth: false,
|
|
9085
|
+
options: {
|
|
9086
|
+
...options,
|
|
9087
|
+
timeout: options?.timeout ?? TOKEN_REQUEST_TIMEOUT_MS,
|
|
9088
|
+
retry: options?.retry ?? false,
|
|
9089
|
+
signal: signal ?? options?.signal,
|
|
9090
|
+
// Never honor a caller's exposeRawAxiosError here: the
|
|
9091
|
+
// grant body carries client secrets and one-time codes.
|
|
9092
|
+
exposeRawAxiosError: false
|
|
9093
|
+
},
|
|
9094
|
+
contentType: "application/x-www-form-urlencoded"
|
|
9095
|
+
});
|
|
9096
|
+
} catch (error) {
|
|
9097
|
+
throw sanitizeTokenError(error, descriptor.errorLabel);
|
|
9098
|
+
}
|
|
9099
|
+
};
|
|
9100
|
+
const refreshGrantParams = (clientId, clientSecret, refreshToken) => ({
|
|
9101
|
+
grant_type: "refresh_token",
|
|
9102
|
+
client_id: clientId,
|
|
9103
|
+
...clientSecret !== void 0 ? { client_secret: clientSecret } : {},
|
|
9104
|
+
refresh_token: refreshToken
|
|
9105
|
+
});
|
|
9106
|
+
const computeTokenExpiry = (response, now = Date.now()) => {
|
|
9107
|
+
const { expires_in } = response;
|
|
9108
|
+
if (!Number.isFinite(expires_in) || expires_in <= 0) {
|
|
9109
|
+
throw new TypeError(
|
|
9110
|
+
`Invalid expires_in ${expires_in}: token lifetime must be a finite, positive number of seconds.`
|
|
9111
|
+
);
|
|
9112
|
+
}
|
|
9113
|
+
return new Date(now + expires_in * 1e3);
|
|
9114
|
+
};
|
|
9115
|
+
const rewriteAuth = (descriptor, auth, accessToken) => {
|
|
9116
|
+
const headers = typeof auth === "object" && auth?.headers !== void 0 ? Object.fromEntries(
|
|
9117
|
+
Object.entries(auth.headers).filter(
|
|
9118
|
+
([key]) => !descriptor.stripHeaders.includes(key)
|
|
9119
|
+
)
|
|
9120
|
+
) : void 0;
|
|
9121
|
+
return {
|
|
9122
|
+
token: accessToken,
|
|
9123
|
+
headers: headers !== void 0 && Object.keys(headers).length > 0 ? headers : void 0
|
|
9124
|
+
};
|
|
9125
|
+
};
|
|
8328
9126
|
class TokenRefresher {
|
|
8329
|
-
|
|
9127
|
+
descriptor;
|
|
9128
|
+
clientId;
|
|
9129
|
+
clientSecret;
|
|
8330
9130
|
refreshToken;
|
|
8331
|
-
provider;
|
|
8332
9131
|
onTokenRefresh;
|
|
8333
9132
|
onTokenRefreshError;
|
|
8334
9133
|
onHookError;
|
|
@@ -8338,13 +9137,14 @@ class TokenRefresher {
|
|
|
8338
9137
|
/**
|
|
8339
9138
|
* Constructs a refresh coordinator from the wiring's fields.
|
|
8340
9139
|
*
|
|
8341
|
-
* @param options - The
|
|
9140
|
+
* @param options - The provider descriptor, the client credentials, the stored refresh token, the diagnostics names, the auth-swap callback, the optional persistence and failure callbacks, and the diagnostics mode.
|
|
8342
9141
|
* @throws A `TypeError` when `options.diagnostics` is defined but not one of `"warn"`, `"hook"`, or `"silent"`.
|
|
8343
9142
|
*/
|
|
8344
9143
|
constructor(options) {
|
|
8345
|
-
this.
|
|
9144
|
+
this.descriptor = options.descriptor;
|
|
9145
|
+
this.clientId = options.clientId;
|
|
9146
|
+
this.clientSecret = options.clientSecret;
|
|
8346
9147
|
this.refreshToken = options.refreshToken;
|
|
8347
|
-
this.provider = options.provider;
|
|
8348
9148
|
this.onTokenRefresh = options.onTokenRefresh;
|
|
8349
9149
|
this.onTokenRefreshError = options.onTokenRefreshError;
|
|
8350
9150
|
this.onHookError = options.onHookError;
|
|
@@ -8354,13 +9154,13 @@ class TokenRefresher {
|
|
|
8354
9154
|
/**
|
|
8355
9155
|
* Runs one operation attempt under the automatic refresh lifecycle.
|
|
8356
9156
|
*
|
|
8357
|
-
* A 401 from the first attempt
|
|
9157
|
+
* A 401 from the first attempt, or an {@link AniLinkAuthError} raised
|
|
8358
9158
|
* before any request because no access token is configured, which lets
|
|
8359
|
-
* a persisted refresh token bootstrap the client
|
|
9159
|
+
* a persisted refresh token bootstrap the client, triggers exactly one
|
|
8360
9160
|
* refresh (concurrent failures share the in-flight refresh) followed by
|
|
8361
|
-
* a single replay with the new auth material. Any other failure
|
|
8362
|
-
* non-401 first attempt, a failed refresh, or a replay that fails again
|
|
8363
|
-
*
|
|
9161
|
+
* a single replay with the new auth material. Any other failure, a
|
|
9162
|
+
* non-401 first attempt, a failed refresh, or a replay that fails again,
|
|
9163
|
+
* surfaces unchanged. A failed refresh grant is reported to
|
|
8364
9164
|
* `onHookError` (under the coordinator's `hookName`) before the
|
|
8365
9165
|
* sanitized error rethrows, so the grant failure is observable through
|
|
8366
9166
|
* the same channel as every other lifecycle failure. The
|
|
@@ -8385,8 +9185,8 @@ class TokenRefresher {
|
|
|
8385
9185
|
} catch (refreshError) {
|
|
8386
9186
|
reportDiagnostic({
|
|
8387
9187
|
kind: "token-refresh",
|
|
8388
|
-
hookName: this.provider.hookName,
|
|
8389
|
-
message: `The ${this.provider.providerLabel} token refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`,
|
|
9188
|
+
hookName: this.descriptor.provider.hookName,
|
|
9189
|
+
message: `The ${this.descriptor.provider.providerLabel} token refresh failed: ${refreshError instanceof Error ? refreshError.message : String(refreshError)}`,
|
|
8390
9190
|
onHookError: this.onHookError,
|
|
8391
9191
|
diagnostics: this.diagnostics,
|
|
8392
9192
|
rawError: refreshError,
|
|
@@ -8405,7 +9205,7 @@ class TokenRefresher {
|
|
|
8405
9205
|
*
|
|
8406
9206
|
* The auth swap and the callback run inside the shared in-flight promise
|
|
8407
9207
|
* so concurrent 401s observe one grant, one swap, and one callback
|
|
8408
|
-
* invocation
|
|
9208
|
+
* invocation, and so the swap always lands in grant order.
|
|
8409
9209
|
*
|
|
8410
9210
|
* @returns The effective token response, with `refresh_token` filled in when the provider omitted it.
|
|
8411
9211
|
*/
|
|
@@ -8437,69 +9237,29 @@ class TokenRefresher {
|
|
|
8437
9237
|
return await this.refreshInFlight;
|
|
8438
9238
|
}
|
|
8439
9239
|
/**
|
|
8440
|
-
* Runs the refresh grant
|
|
9240
|
+
* Runs the refresh grant against the descriptor's token endpoint and
|
|
9241
|
+
* stores the rotated refresh token.
|
|
8441
9242
|
*
|
|
8442
9243
|
* @returns The effective token response.
|
|
8443
9244
|
*/
|
|
8444
9245
|
async performRefresh() {
|
|
8445
|
-
const response = await
|
|
9246
|
+
const response = await requestTokenGrant(
|
|
9247
|
+
this.descriptor,
|
|
9248
|
+
refreshGrantParams(this.clientId, this.clientSecret, this.refreshToken)
|
|
9249
|
+
);
|
|
8446
9250
|
this.refreshToken = response.refresh_token ?? this.refreshToken;
|
|
8447
9251
|
return { ...response, refresh_token: this.refreshToken };
|
|
8448
9252
|
}
|
|
8449
9253
|
}
|
|
8450
9254
|
|
|
8451
|
-
const sanitizeTokenError = (error, label) => {
|
|
8452
|
-
if (error instanceof AniLinkRestError) {
|
|
8453
|
-
const relabeled = new AniLinkRestError(error.status, error.data, void 0, {
|
|
8454
|
-
rateLimit: error.rateLimit,
|
|
8455
|
-
contentType: error.contentType,
|
|
8456
|
-
requestId: error.requestId
|
|
8457
|
-
});
|
|
8458
|
-
if (error.rawAxiosError === void 0) {
|
|
8459
|
-
relabeled.cause = error;
|
|
8460
|
-
}
|
|
8461
|
-
relabeled.message = `${label} failed with status ${error.status}.`;
|
|
8462
|
-
return relabeled;
|
|
8463
|
-
}
|
|
8464
|
-
if (error instanceof AniLinkApiError) {
|
|
8465
|
-
const relabeled = new AniLinkApiError(error.status, error.data, void 0, {
|
|
8466
|
-
rateLimit: error.rateLimit,
|
|
8467
|
-
contentType: error.contentType,
|
|
8468
|
-
requestId: error.requestId
|
|
8469
|
-
});
|
|
8470
|
-
if (error.rawAxiosError === void 0) {
|
|
8471
|
-
relabeled.cause = error;
|
|
8472
|
-
}
|
|
8473
|
-
relabeled.message = `${label} failed with status ${error.status}.`;
|
|
8474
|
-
return relabeled;
|
|
8475
|
-
}
|
|
8476
|
-
if (error instanceof AniLinkError) {
|
|
8477
|
-
return error;
|
|
8478
|
-
}
|
|
8479
|
-
if (axios.isCancel(error)) {
|
|
8480
|
-
return new AniLinkNetworkError(AniLinkErrorCodes.ABORTED, `${label} was cancelled.`);
|
|
8481
|
-
}
|
|
8482
|
-
if (axios.isAxiosError(error)) {
|
|
8483
|
-
if (error.response?.status !== void 0) {
|
|
8484
|
-
const status = error.response.status;
|
|
8485
|
-
const apiError = new AniLinkApiError(status, error.response.data);
|
|
8486
|
-
apiError.message = `${label} failed with status ${status}.`;
|
|
8487
|
-
return apiError;
|
|
8488
|
-
}
|
|
8489
|
-
if (error.code === "ECONNABORTED" || error.code === "ETIMEDOUT") {
|
|
8490
|
-
return new AniLinkNetworkError(AniLinkErrorCodes.TIMEOUT, `${label} timed out.`);
|
|
8491
|
-
}
|
|
8492
|
-
return new AniLinkNetworkError(
|
|
8493
|
-
AniLinkErrorCodes.NETWORK,
|
|
8494
|
-
`${label} failed due to a network error.`
|
|
8495
|
-
);
|
|
8496
|
-
}
|
|
8497
|
-
return new AniLinkError(`${label} failed.`, AniLinkErrorCodes.UNKNOWN);
|
|
8498
|
-
};
|
|
8499
|
-
|
|
8500
|
-
const AUTH_TOKEN_TIMEOUT_MS = 1e4;
|
|
8501
9255
|
const ANILIST_TOKEN_URL = "https://anilist.co/api/v2/oauth/token";
|
|
8502
9256
|
const ANILIST_AUTHORIZE_URL = "https://anilist.co/api/v2/oauth/authorize";
|
|
9257
|
+
const ANILIST_TOKEN_GRANT = {
|
|
9258
|
+
tokenUrl: ANILIST_TOKEN_URL,
|
|
9259
|
+
errorLabel: "AniList token request",
|
|
9260
|
+
stripHeaders: [],
|
|
9261
|
+
provider: { hookName: "aniListTokenRefresh", providerLabel: "AniList" }
|
|
9262
|
+
};
|
|
8503
9263
|
const buildAuthorizationUrl = (clientId, redirectUri, state) => {
|
|
8504
9264
|
let url = `${ANILIST_AUTHORIZE_URL}?client_id=${encodeURIComponent(
|
|
8505
9265
|
clientId
|
|
@@ -8509,29 +9269,8 @@ const buildAuthorizationUrl = (clientId, redirectUri, state) => {
|
|
|
8509
9269
|
}
|
|
8510
9270
|
return url;
|
|
8511
9271
|
};
|
|
8512
|
-
const
|
|
8513
|
-
|
|
8514
|
-
const mergedOptions = {
|
|
8515
|
-
...options,
|
|
8516
|
-
timeout: options?.timeout ?? AUTH_TOKEN_TIMEOUT_MS,
|
|
8517
|
-
retry: options?.retry ?? false,
|
|
8518
|
-
signal: signal ?? options?.signal,
|
|
8519
|
-
// Never honor a caller's exposeRawAxiosError here: the token request
|
|
8520
|
-
// body carries the client secret and grant credentials.
|
|
8521
|
-
exposeRawAxiosError: false
|
|
8522
|
-
};
|
|
8523
|
-
try {
|
|
8524
|
-
const body = new URLSearchParams(params).toString();
|
|
8525
|
-
return await sendRequest(ANILIST_TOKEN_URL, "POST", body, void 0, {
|
|
8526
|
-
requiresAuth: false,
|
|
8527
|
-
options: mergedOptions,
|
|
8528
|
-
contentType: "application/x-www-form-urlencoded"
|
|
8529
|
-
});
|
|
8530
|
-
} catch (error) {
|
|
8531
|
-
throw normalizeTokenRequestError(error);
|
|
8532
|
-
}
|
|
8533
|
-
};
|
|
8534
|
-
const getAccessToken = async (clientId, clientSecret, code, redirectUri, signal, options) => requestToken(
|
|
9272
|
+
const getAccessToken = async (clientId, clientSecret, code, redirectUri, signal, options) => requestTokenGrant(
|
|
9273
|
+
ANILIST_TOKEN_GRANT,
|
|
8535
9274
|
{
|
|
8536
9275
|
grant_type: "authorization_code",
|
|
8537
9276
|
client_id: clientId,
|
|
@@ -8547,46 +9286,19 @@ const getAccessToken = async (clientId, clientSecret, code, redirectUri, signal,
|
|
|
8547
9286
|
signal,
|
|
8548
9287
|
options
|
|
8549
9288
|
);
|
|
8550
|
-
const refreshAccessToken = async (clientId, clientSecret, refreshToken, signal, options) =>
|
|
8551
|
-
|
|
8552
|
-
|
|
8553
|
-
client_id: clientId,
|
|
8554
|
-
client_secret: clientSecret,
|
|
8555
|
-
refresh_token: refreshToken
|
|
8556
|
-
},
|
|
9289
|
+
const refreshAccessToken = async (clientId, clientSecret, refreshToken, signal, options) => requestTokenGrant(
|
|
9290
|
+
ANILIST_TOKEN_GRANT,
|
|
9291
|
+
refreshGrantParams(clientId, clientSecret, refreshToken),
|
|
8557
9292
|
signal,
|
|
8558
9293
|
options
|
|
8559
9294
|
);
|
|
8560
|
-
const getTokenExpiry = (response, now
|
|
8561
|
-
const { expires_in } = response;
|
|
8562
|
-
if (!Number.isFinite(expires_in) || expires_in <= 0) {
|
|
8563
|
-
throw new TypeError(
|
|
8564
|
-
`Invalid expires_in ${expires_in}: token lifetime must be a finite, positive number of seconds.`
|
|
8565
|
-
);
|
|
8566
|
-
}
|
|
8567
|
-
return new Date(now + expires_in * 1e3);
|
|
8568
|
-
};
|
|
9295
|
+
const getTokenExpiry = (response, now) => computeTokenExpiry(response, now);
|
|
8569
9296
|
|
|
8570
|
-
const buildAniListTokenRefresher = (options) => {
|
|
8571
|
-
|
|
8572
|
-
|
|
8573
|
-
|
|
8574
|
-
|
|
8575
|
-
provider: { hookName: "aniListTokenRefresh", providerLabel: "AniList" },
|
|
8576
|
-
onTokenRefresh: options.onTokenRefresh,
|
|
8577
|
-
onTokenRefreshError: options.onTokenRefreshError,
|
|
8578
|
-
onHookError: options.onHookError,
|
|
8579
|
-
diagnostics: options.diagnostics,
|
|
8580
|
-
applyAccessToken: options.applyAccessToken
|
|
8581
|
-
});
|
|
8582
|
-
};
|
|
8583
|
-
const buildRefreshedAuth$1 = (auth, accessToken) => {
|
|
8584
|
-
const headers = typeof auth === "object" && auth?.headers !== void 0 ? Object.fromEntries(Object.entries(auth.headers)) : void 0;
|
|
8585
|
-
return {
|
|
8586
|
-
token: accessToken,
|
|
8587
|
-
headers: headers !== void 0 && Object.keys(headers).length > 0 ? headers : void 0
|
|
8588
|
-
};
|
|
8589
|
-
};
|
|
9297
|
+
const buildAniListTokenRefresher = (options) => new TokenRefresher({
|
|
9298
|
+
descriptor: ANILIST_TOKEN_GRANT,
|
|
9299
|
+
...options
|
|
9300
|
+
});
|
|
9301
|
+
const buildRefreshedAuth$1 = (auth, accessToken) => rewriteAuth(ANILIST_TOKEN_GRANT, auth, accessToken);
|
|
8590
9302
|
|
|
8591
9303
|
function validateCategoryMethods(category) {
|
|
8592
9304
|
for (const entry of ANILIST_OPERATION_REGISTRY[category]) {
|
|
@@ -8684,6 +9396,15 @@ function buildAniListWiring(authToken, options, stateOwner, credentials) {
|
|
|
8684
9396
|
writable: false
|
|
8685
9397
|
});
|
|
8686
9398
|
let customBound;
|
|
9399
|
+
let customPageBound;
|
|
9400
|
+
const page = pageFacade;
|
|
9401
|
+
const notificationsFetch = (variables, options2) => page.notifications(variables, options2);
|
|
9402
|
+
const activitiesFetch = (variables, options2) => page.activities(variables, options2);
|
|
9403
|
+
const watchFacade = {
|
|
9404
|
+
notifications: (watchOptions) => watchNotifications(notificationsFetch, watchOptions),
|
|
9405
|
+
activity: (watchOptions) => watchActivity(activitiesFetch, watchOptions)
|
|
9406
|
+
};
|
|
9407
|
+
const mapExternalIdsFacade = buildMapExternalIdsFacade(options, sharedStateOwner);
|
|
8687
9408
|
return Object.defineProperties(
|
|
8688
9409
|
{
|
|
8689
9410
|
query: queryFacade,
|
|
@@ -8694,7 +9415,9 @@ function buildAniListWiring(authToken, options, stateOwner, credentials) {
|
|
|
8694
9415
|
fuzzyDate,
|
|
8695
9416
|
fuzzyDateInt,
|
|
8696
9417
|
flattenMediaListCollection,
|
|
8697
|
-
crossLink
|
|
9418
|
+
crossLink,
|
|
9419
|
+
mapExternalIds: mapExternalIdsFacade,
|
|
9420
|
+
watch: watchFacade
|
|
8698
9421
|
},
|
|
8699
9422
|
{
|
|
8700
9423
|
custom: {
|
|
@@ -8712,6 +9435,24 @@ function buildAniListWiring(authToken, options, stateOwner, credentials) {
|
|
|
8712
9435
|
}
|
|
8713
9436
|
return customBound;
|
|
8714
9437
|
}
|
|
9438
|
+
},
|
|
9439
|
+
customPage: {
|
|
9440
|
+
enumerable: true,
|
|
9441
|
+
configurable: false,
|
|
9442
|
+
get() {
|
|
9443
|
+
if (customPageBound === void 0) {
|
|
9444
|
+
const customPageInstance = new CustomRequest(
|
|
9445
|
+
getAuth(),
|
|
9446
|
+
options,
|
|
9447
|
+
sharedStateOwner
|
|
9448
|
+
);
|
|
9449
|
+
trackOperation?.(customPageInstance);
|
|
9450
|
+
customPageBound = maybeWrap(
|
|
9451
|
+
customPageInstance.customPage.bind(customPageInstance)
|
|
9452
|
+
);
|
|
9453
|
+
}
|
|
9454
|
+
return customPageBound;
|
|
9455
|
+
}
|
|
8715
9456
|
}
|
|
8716
9457
|
}
|
|
8717
9458
|
);
|
|
@@ -8721,6 +9462,162 @@ function buildAniListApi(authToken, options, stateOwner, credentials) {
|
|
|
8721
9462
|
return buildAniListWiring(authToken, options, stateOwner, credentials);
|
|
8722
9463
|
}
|
|
8723
9464
|
|
|
9465
|
+
const MAL_API_BASE_URL = "https://api.myanimelist.net/v2";
|
|
9466
|
+
const MAL_AUTHORIZE_URL = "https://myanimelist.net/v1/oauth2/authorize";
|
|
9467
|
+
const MAL_TOKEN_URL = "https://myanimelist.net/v1/oauth2/token";
|
|
9468
|
+
const MAL_API_REFERENCE = "https://myanimelist.net/apiconfig/references/api/v2";
|
|
9469
|
+
const DEFAULT_MAL_ANIME_FIELD_KEYS = {
|
|
9470
|
+
id: true,
|
|
9471
|
+
title: true,
|
|
9472
|
+
main_picture: true,
|
|
9473
|
+
synopsis: true,
|
|
9474
|
+
status: true,
|
|
9475
|
+
mean: true,
|
|
9476
|
+
num_episodes: true,
|
|
9477
|
+
media_type: true,
|
|
9478
|
+
start_date: true,
|
|
9479
|
+
broadcast: true,
|
|
9480
|
+
average_episode_duration: true
|
|
9481
|
+
};
|
|
9482
|
+
const DEFAULT_MAL_ANIME_FIELDS = Object.keys(
|
|
9483
|
+
DEFAULT_MAL_ANIME_FIELD_KEYS
|
|
9484
|
+
);
|
|
9485
|
+
const formatMalFields = (fields) => typeof fields === "string" ? fields : fields?.join(",");
|
|
9486
|
+
|
|
9487
|
+
const MAL_TOKEN_GRANT = {
|
|
9488
|
+
tokenUrl: MAL_TOKEN_URL,
|
|
9489
|
+
errorLabel: "MAL token request",
|
|
9490
|
+
stripHeaders: ["X-MAL-CLIENT-ID"],
|
|
9491
|
+
provider: { hookName: "malTokenRefresh", providerLabel: "MAL" }
|
|
9492
|
+
};
|
|
9493
|
+
const validateMalPkceValue = (value, name) => {
|
|
9494
|
+
if (!/^[-A-Za-z0-9._~]{43,128}$/.test(value)) {
|
|
9495
|
+
throw new TypeError(`${name} must contain 43 to 128 RFC 7636 unreserved characters.`);
|
|
9496
|
+
}
|
|
9497
|
+
};
|
|
9498
|
+
const buildMalAuthorizationUrl = (clientId, codeChallenge, state) => {
|
|
9499
|
+
validateMalPkceValue(codeChallenge, "codeChallenge");
|
|
9500
|
+
const params = new URLSearchParams({
|
|
9501
|
+
response_type: "code",
|
|
9502
|
+
client_id: clientId,
|
|
9503
|
+
code_challenge: codeChallenge,
|
|
9504
|
+
code_challenge_method: "plain"
|
|
9505
|
+
});
|
|
9506
|
+
if (state !== void 0) params.set("state", state);
|
|
9507
|
+
return `${MAL_AUTHORIZE_URL}?${params.toString().replaceAll("+", "%20")}`;
|
|
9508
|
+
};
|
|
9509
|
+
const getMalAccessToken = async (request) => {
|
|
9510
|
+
validateMalPkceValue(request.codeVerifier, "codeVerifier");
|
|
9511
|
+
return requestTokenGrant(
|
|
9512
|
+
MAL_TOKEN_GRANT,
|
|
9513
|
+
{
|
|
9514
|
+
client_id: request.clientId,
|
|
9515
|
+
code: request.code,
|
|
9516
|
+
code_verifier: request.codeVerifier,
|
|
9517
|
+
grant_type: "authorization_code",
|
|
9518
|
+
...request.clientSecret === void 0 ? {} : { client_secret: request.clientSecret }
|
|
9519
|
+
},
|
|
9520
|
+
void 0,
|
|
9521
|
+
request.options
|
|
9522
|
+
);
|
|
9523
|
+
};
|
|
9524
|
+
const refreshMalAccessToken = (request) => requestTokenGrant(
|
|
9525
|
+
MAL_TOKEN_GRANT,
|
|
9526
|
+
refreshGrantParams(request.clientId, request.clientSecret, request.refreshToken),
|
|
9527
|
+
void 0,
|
|
9528
|
+
request.options
|
|
9529
|
+
);
|
|
9530
|
+
const getMalTokenExpiry = (response, now) => computeTokenExpiry(response, now);
|
|
9531
|
+
|
|
9532
|
+
const MAX_PER_PAGE = 100;
|
|
9533
|
+
const DEFAULT_PER_PAGE = MAX_PER_PAGE;
|
|
9534
|
+
const DEFAULT_MAX_PAGES = 100;
|
|
9535
|
+
const DEFAULT_CONCURRENCY = 1;
|
|
9536
|
+
const MAL_DEFAULTS = {
|
|
9537
|
+
naming: "page",
|
|
9538
|
+
maxPerEntry: MAX_PER_PAGE,
|
|
9539
|
+
defaultPerEntry: DEFAULT_PER_PAGE,
|
|
9540
|
+
defaultMaxEntries: DEFAULT_MAX_PAGES,
|
|
9541
|
+
defaultConcurrency: DEFAULT_CONCURRENCY
|
|
9542
|
+
};
|
|
9543
|
+
const safeCallback = (callback, name, onHookError, diagnostics, payload) => {
|
|
9544
|
+
safeInvoke(callback, name, onHookError, diagnostics, payload);
|
|
9545
|
+
};
|
|
9546
|
+
function extractHasMore(response, perPage) {
|
|
9547
|
+
if (!Array.isArray(response.data)) {
|
|
9548
|
+
return false;
|
|
9549
|
+
}
|
|
9550
|
+
if (typeof response.paging === "object" && response.paging !== null) {
|
|
9551
|
+
return response.paging.next != null;
|
|
9552
|
+
}
|
|
9553
|
+
return response.data.length >= perPage;
|
|
9554
|
+
}
|
|
9555
|
+
async function malPaginate(fetchPage, options) {
|
|
9556
|
+
const {
|
|
9557
|
+
perEntry: perPage,
|
|
9558
|
+
startEntry: startPage,
|
|
9559
|
+
maxEntries: maxPages,
|
|
9560
|
+
concurrency,
|
|
9561
|
+
diagnostics
|
|
9562
|
+
} = resolvePaginationOptions(options, MAL_DEFAULTS);
|
|
9563
|
+
const { signal, dispose } = bridgeAbortSignal(options?.signal);
|
|
9564
|
+
try {
|
|
9565
|
+
const { responses, count, truncated } = await fetchWithLookAhead(
|
|
9566
|
+
(page) => fetchPage(page, perPage, signal),
|
|
9567
|
+
(response) => extractHasMore(response, perPage),
|
|
9568
|
+
startPage,
|
|
9569
|
+
maxPages,
|
|
9570
|
+
concurrency,
|
|
9571
|
+
signal
|
|
9572
|
+
);
|
|
9573
|
+
const items = [];
|
|
9574
|
+
const pages = [];
|
|
9575
|
+
for (const response of responses) {
|
|
9576
|
+
const raw = response.data;
|
|
9577
|
+
if (raw === void 0) {
|
|
9578
|
+
throw new AniLinkValidationError([
|
|
9579
|
+
'malPaginate: the page response has no "data" key. Check the fetchPage closure returns the raw MAL list response.'
|
|
9580
|
+
]);
|
|
9581
|
+
}
|
|
9582
|
+
const pageItems = Array.isArray(raw) ? raw : [];
|
|
9583
|
+
pages.push({ paging: response.paging, items: pageItems });
|
|
9584
|
+
items.push(...pageItems);
|
|
9585
|
+
safeCallback(options?.onPage, "onPage", options?.onHookError, diagnostics, {
|
|
9586
|
+
paging: response.paging,
|
|
9587
|
+
items: pageItems
|
|
9588
|
+
});
|
|
9589
|
+
}
|
|
9590
|
+
return { items, pages, pageCount: count, truncated };
|
|
9591
|
+
} finally {
|
|
9592
|
+
dispose();
|
|
9593
|
+
}
|
|
9594
|
+
}
|
|
9595
|
+
async function* malPaginatePages(fetchPage, options) {
|
|
9596
|
+
const {
|
|
9597
|
+
perEntry: perPage,
|
|
9598
|
+
startEntry: startPage,
|
|
9599
|
+
maxEntries: maxPages,
|
|
9600
|
+
concurrency,
|
|
9601
|
+
diagnostics
|
|
9602
|
+
} = resolvePaginationOptions(options, MAL_DEFAULTS);
|
|
9603
|
+
for await (const response of streamNumericPages(
|
|
9604
|
+
fetchPage,
|
|
9605
|
+
// Invert {@link extractHasMore} so both traversal helpers use the
|
|
9606
|
+
// same terminal condition. Malformed `data` ends traversal in every
|
|
9607
|
+
// branch. When present, MAL's `paging` node is authoritative. A
|
|
9608
|
+
// missing `next` URL ends traversal even on a full page. Without a
|
|
9609
|
+
// `paging` node, a short page ends traversal.
|
|
9610
|
+
(page) => !extractHasMore(page, perPage),
|
|
9611
|
+
{ perPage, startPage, maxPages, concurrency, signal: options?.signal }
|
|
9612
|
+
)) {
|
|
9613
|
+
safeCallback(options?.onPage, "onPage", options?.onHookError, diagnostics, {
|
|
9614
|
+
paging: response.paging,
|
|
9615
|
+
items: Array.isArray(response.data) ? response.data : []
|
|
9616
|
+
});
|
|
9617
|
+
yield response;
|
|
9618
|
+
}
|
|
9619
|
+
}
|
|
9620
|
+
|
|
8724
9621
|
const buildQueryString = (params) => {
|
|
8725
9622
|
const segments = [];
|
|
8726
9623
|
for (const [key, value] of Object.entries(params)) {
|
|
@@ -8748,16 +9645,17 @@ class RestOperation extends BaseOperation {
|
|
|
8748
9645
|
/**
|
|
8749
9646
|
* Sends one REST call through the shared transport pipeline.
|
|
8750
9647
|
*
|
|
8751
|
-
* GET and DELETE calls pass
|
|
8752
|
-
*
|
|
8753
|
-
*
|
|
8754
|
-
* happens.
|
|
9648
|
+
* GET and DELETE calls pass parameters as a query string. POST, PUT, and
|
|
9649
|
+
* PATCH calls send them as a JSON body. `execute` returns response bodies
|
|
9650
|
+
* verbatim because REST providers have no GraphQL-style envelope to unwrap.
|
|
8755
9651
|
*
|
|
8756
9652
|
* @typeParam T - The expected parsed response body.
|
|
8757
9653
|
* @param path - The endpoint path beginning with `/` (for example `/anime/{id}`); placeholders are substituted from `pathParams` before interpolation into the URL.
|
|
8758
9654
|
* @param options - The declarative request contract: method, auth requirement, content type, query/body/pathParams, and per-request transport settings.
|
|
8759
9655
|
* @returns The parsed response body as-is.
|
|
8760
|
-
* @throws An {@link AniLinkAuthError} when `requiresAuth` is true and no token is set
|
|
9656
|
+
* @throws An {@link AniLinkAuthError} when `requiresAuth` is true and no token is set.
|
|
9657
|
+
* @throws An {@link AniLinkValidationError} when a `{placeholder}` in `path` has no matching `pathParams` entry.
|
|
9658
|
+
* @throws A normalized {@link AniLinkError} (typically `AniLinkRestError`) when the request fails.
|
|
8761
9659
|
*/
|
|
8762
9660
|
async execute(path, options = {}) {
|
|
8763
9661
|
const {
|
|
@@ -8793,25 +9691,6 @@ class RestOperation extends BaseOperation {
|
|
|
8793
9691
|
}
|
|
8794
9692
|
}
|
|
8795
9693
|
|
|
8796
|
-
const MAL_API_BASE_URL = "https://api.myanimelist.net/v2";
|
|
8797
|
-
const MAL_AUTHORIZE_URL = "https://myanimelist.net/v1/oauth2/authorize";
|
|
8798
|
-
const MAL_TOKEN_URL = "https://myanimelist.net/v1/oauth2/token";
|
|
8799
|
-
const MAL_API_REFERENCE = "https://myanimelist.net/apiconfig/references/api/v2";
|
|
8800
|
-
const DEFAULT_MAL_ANIME_FIELDS = [
|
|
8801
|
-
"id",
|
|
8802
|
-
"title",
|
|
8803
|
-
"main_picture",
|
|
8804
|
-
"synopsis",
|
|
8805
|
-
"status",
|
|
8806
|
-
"mean",
|
|
8807
|
-
"num_episodes",
|
|
8808
|
-
"media_type",
|
|
8809
|
-
"start_date",
|
|
8810
|
-
"broadcast",
|
|
8811
|
-
"average_episode_duration"
|
|
8812
|
-
];
|
|
8813
|
-
const formatMalFields = (fields) => typeof fields === "string" ? fields : fields?.join(",");
|
|
8814
|
-
|
|
8815
9694
|
class MalAnimeOperation extends RestOperation {
|
|
8816
9695
|
/** The base URL for MyAnimeList API v2, from {@link MAL_API_BASE_URL}. */
|
|
8817
9696
|
baseUrl = MAL_API_BASE_URL;
|
|
@@ -8824,8 +9703,6 @@ class MalAnimeOperation extends RestOperation {
|
|
|
8824
9703
|
"status",
|
|
8825
9704
|
"num_watched_episodes",
|
|
8826
9705
|
"score",
|
|
8827
|
-
"start_date",
|
|
8828
|
-
"finish_date",
|
|
8829
9706
|
"comments",
|
|
8830
9707
|
"is_rewatching",
|
|
8831
9708
|
"num_times_rewatched",
|
|
@@ -8865,8 +9742,9 @@ class MalAnimeOperation extends RestOperation {
|
|
|
8865
9742
|
* It calls `GET /anime` through `RestOperation.execute` with the `q` keyword
|
|
8866
9743
|
* plus the `limit`/`offset` paging filters and returns a
|
|
8867
9744
|
* {@link MalAnimeSearchResponse} page of `MalAnimeSearchEntry` entries
|
|
8868
|
-
* shaped by {@link MalRequestOptions.fields}. The facade
|
|
8869
|
-
* `MyAnimeListAnimeApi.search
|
|
9745
|
+
* shaped by {@link MalRequestOptions.fields}. The facade exposes this
|
|
9746
|
+
* method as `MyAnimeListAnimeApi.search`. It does not require an access
|
|
9747
|
+
* token.
|
|
8870
9748
|
*
|
|
8871
9749
|
* @param params - The search inputs; a {@link MalAnimeSearchParams} carrying the keyword plus the optional paging filters.
|
|
8872
9750
|
* @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -8905,7 +9783,11 @@ class MalAnimeOperation extends RestOperation {
|
|
|
8905
9783
|
/**
|
|
8906
9784
|
* {@link MalAnimeOperation.seasonal} gets the anime of one broadcast season.
|
|
8907
9785
|
*
|
|
8908
|
-
* It calls `GET /anime/season/{year}/{season}` through
|
|
9786
|
+
* It calls `GET /anime/season/{year}/{season}` through
|
|
9787
|
+
* `RestOperation.execute` and returns a {@link MalSeasonalAnimeResponse} page
|
|
9788
|
+
* of `MalSeasonalAnime` entries shaped by {@link MalRequestOptions.fields}.
|
|
9789
|
+
* The facade exposes this method as `MyAnimeListAnimeApi.seasonal`. It does
|
|
9790
|
+
* not require an access token.
|
|
8909
9791
|
*
|
|
8910
9792
|
* @param params - The seasonal read inputs; a {@link MalSeasonalParams} carrying the year and broadcast window.
|
|
8911
9793
|
* @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -8936,7 +9818,11 @@ class MalAnimeOperation extends RestOperation {
|
|
|
8936
9818
|
/**
|
|
8937
9819
|
* {@link MalAnimeOperation.ranking} gets one of MyAnimeList's anime ranking lists.
|
|
8938
9820
|
*
|
|
8939
|
-
* It calls `GET /anime/ranking` through `RestOperation.execute` with the
|
|
9821
|
+
* It calls `GET /anime/ranking` through `RestOperation.execute` with the
|
|
9822
|
+
* `ranking_type` query parameter and returns a {@link MalAnimeRankingResponse}
|
|
9823
|
+
* page of `MalRankingEntry` entries shaped by {@link MalRequestOptions.fields}.
|
|
9824
|
+
* The facade exposes this method as `MyAnimeListAnimeApi.ranking`. It does
|
|
9825
|
+
* not require an access token.
|
|
8940
9826
|
*
|
|
8941
9827
|
* @param params - The ranking read inputs; a {@link MalRankingParams} carrying the ranking list to fetch.
|
|
8942
9828
|
* @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -8999,11 +9885,11 @@ class MalAnimeOperation extends RestOperation {
|
|
|
8999
9885
|
* MAL's list-status endpoints require.
|
|
9000
9886
|
*
|
|
9001
9887
|
* MAL documents `PATCH /anime/{id}/my_list_status` with an
|
|
9002
|
-
* `application/x-www-form-urlencoded` request body, not JSON.
|
|
9003
|
-
* known list-status fields
|
|
9004
|
-
*
|
|
9005
|
-
*
|
|
9006
|
-
*
|
|
9888
|
+
* `application/x-www-form-urlencoded` request body, not JSON. The encoder
|
|
9889
|
+
* includes only known list-status fields. It drops extra JavaScript
|
|
9890
|
+
* properties, such as the typo `num_watched_episode`, rather than sending
|
|
9891
|
+
* fields MAL would ignore. It joins array values (`tags`) into the
|
|
9892
|
+
* comma-separated string MAL expects.
|
|
9007
9893
|
*
|
|
9008
9894
|
* @param payload - The list-status fields to update.
|
|
9009
9895
|
* @returns The encoded body string, safe to pass as the request `data`.
|
|
@@ -9100,9 +9986,10 @@ class MalForumOperation extends RestOperation {
|
|
|
9100
9986
|
* {@link MalForumOperation.boards} gets the MyAnimeList forum board tree.
|
|
9101
9987
|
*
|
|
9102
9988
|
* It calls `GET /forum/boards` through `RestOperation.execute` and returns
|
|
9103
|
-
* a {@link MalForumBoardsResponse}
|
|
9104
|
-
* each
|
|
9105
|
-
*
|
|
9989
|
+
* a {@link MalForumBoardsResponse} containing {@link MalForumCategory}
|
|
9990
|
+
* entries, each with {@link MalForumBoard} entries and subboards. The
|
|
9991
|
+
* facade exposes this method as `MyAnimeListForumApi.boards`. It does not
|
|
9992
|
+
* require an access token.
|
|
9106
9993
|
*
|
|
9107
9994
|
* @param options - Optional transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
9108
9995
|
* @returns The forum board tree, a {@link MalForumBoardsResponse}.
|
|
@@ -9129,8 +10016,8 @@ class MalForumOperation extends RestOperation {
|
|
|
9129
10016
|
* `topic_user_name`/`user_name` creator filters, and the `sort` and
|
|
9130
10017
|
* `limit`/`offset` paging filters, returning a
|
|
9131
10018
|
* {@link MalForumTopicsResponse} page of {@link MalForumTopicSummary}
|
|
9132
|
-
* entries. The facade
|
|
9133
|
-
*
|
|
10019
|
+
* entries. The facade exposes this method as `MyAnimeListForumApi.topics`.
|
|
10020
|
+
* It does not require an access token.
|
|
9134
10021
|
*
|
|
9135
10022
|
* @param params - The topic-list read inputs; a {@link MalForumTopicsParams} carrying the optional board, keyword, creator, sort, and paging filters.
|
|
9136
10023
|
* @param options - Optional transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -9169,8 +10056,9 @@ class MalForumOperation extends RestOperation {
|
|
|
9169
10056
|
* It calls `GET /forum/topic/{id}` through `RestOperation.execute` with
|
|
9170
10057
|
* the `limit`/`offset` post-paging filters and returns a
|
|
9171
10058
|
* {@link MalForumTopicResponse} whose `data` carries the topic's
|
|
9172
|
-
* {@link MalForumTopicDetail}
|
|
9173
|
-
*
|
|
10059
|
+
* {@link MalForumTopicDetail}, which contains the title, posts, and poll.
|
|
10060
|
+
* The facade exposes this method as `MyAnimeListForumApi.topic`. It does
|
|
10061
|
+
* not require an access token.
|
|
9174
10062
|
*
|
|
9175
10063
|
* @param params - The topic read inputs; a {@link MalForumTopicParams} carrying the topic ID plus the optional post-paging filters.
|
|
9176
10064
|
* @param options - Optional transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -9210,8 +10098,6 @@ class MalMangaOperation extends RestOperation {
|
|
|
9210
10098
|
"num_chapters_read",
|
|
9211
10099
|
"num_volumes_read",
|
|
9212
10100
|
"score",
|
|
9213
|
-
"start_date",
|
|
9214
|
-
"finish_date",
|
|
9215
10101
|
"comments",
|
|
9216
10102
|
"is_rereading",
|
|
9217
10103
|
"num_times_reread",
|
|
@@ -9252,8 +10138,9 @@ class MalMangaOperation extends RestOperation {
|
|
|
9252
10138
|
* It calls `GET /manga` through `RestOperation.execute` with the `q` keyword
|
|
9253
10139
|
* plus the `limit`/`offset` paging filters and returns a
|
|
9254
10140
|
* {@link MalMangaSearchResponse} page of `MalMangaSearchEntry` entries
|
|
9255
|
-
* shaped by {@link MalRequestOptions.fields}. The facade
|
|
9256
|
-
* `MyAnimeListMangaApi.search
|
|
10141
|
+
* shaped by {@link MalRequestOptions.fields}. The facade exposes this
|
|
10142
|
+
* method as `MyAnimeListMangaApi.search`. It does not require an access
|
|
10143
|
+
* token.
|
|
9257
10144
|
*
|
|
9258
10145
|
* @param params - The search inputs; a {@link MalMangaSearchParams} carrying the keyword plus the optional paging filters.
|
|
9259
10146
|
* @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -9295,8 +10182,8 @@ class MalMangaOperation extends RestOperation {
|
|
|
9295
10182
|
* It calls `GET /manga/ranking` through `RestOperation.execute` with the
|
|
9296
10183
|
* `ranking_type` query parameter and returns a {@link MalMangaRankingResponse}
|
|
9297
10184
|
* page of `MalMangaRankingEntry` entries shaped by
|
|
9298
|
-
* {@link MalRequestOptions.fields}. The facade
|
|
9299
|
-
* `MyAnimeListMangaApi.ranking
|
|
10185
|
+
* {@link MalRequestOptions.fields}. The facade exposes this method as
|
|
10186
|
+
* `MyAnimeListMangaApi.ranking`. It does not require an access token.
|
|
9300
10187
|
*
|
|
9301
10188
|
* @param params - The ranking read inputs; a {@link MalMangaRankingParams} carrying the ranking list to fetch.
|
|
9302
10189
|
* @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -9331,11 +10218,11 @@ class MalMangaOperation extends RestOperation {
|
|
|
9331
10218
|
* MAL's list-status endpoints require.
|
|
9332
10219
|
*
|
|
9333
10220
|
* MAL documents `PATCH /manga/{id}/my_list_status` with an
|
|
9334
|
-
* `application/x-www-form-urlencoded` request body, not JSON.
|
|
9335
|
-
* known list-status fields
|
|
9336
|
-
*
|
|
9337
|
-
*
|
|
9338
|
-
*
|
|
10221
|
+
* `application/x-www-form-urlencoded` request body, not JSON. The encoder
|
|
10222
|
+
* includes only known list-status fields. It drops extra JavaScript
|
|
10223
|
+
* properties, such as the typo `num_chapter_read`, rather than sending
|
|
10224
|
+
* fields MAL would ignore. It joins array values (`tags`) into the
|
|
10225
|
+
* comma-separated string MAL expects.
|
|
9339
10226
|
*
|
|
9340
10227
|
* @param payload - The list-status fields to update.
|
|
9341
10228
|
* @returns The encoded body string, safe to pass as the request `data`.
|
|
@@ -9429,17 +10316,17 @@ class MalUserOperation extends RestOperation {
|
|
|
9429
10316
|
/** The base URL for MyAnimeList API v2, from {@link MAL_API_BASE_URL}. */
|
|
9430
10317
|
baseUrl = MAL_API_BASE_URL;
|
|
9431
10318
|
/**
|
|
9432
|
-
* Normalizes a `username` argument for the `@me` check
|
|
9433
|
-
*
|
|
10319
|
+
* Normalizes a `username` argument for the `@me` check by trimming and
|
|
10320
|
+
* lowercasing it, so `@ME` and `" @me "` resolve the authenticated user too.
|
|
9434
10321
|
*/
|
|
9435
10322
|
static normalizeUsername(username) {
|
|
9436
10323
|
return username.trim().toLowerCase();
|
|
9437
10324
|
}
|
|
9438
10325
|
/**
|
|
9439
10326
|
* Validates a normalized username before it is interpolated into a URL
|
|
9440
|
-
* path
|
|
9441
|
-
* path, so it fails fast with a clear client-side message instead of
|
|
9442
|
-
*
|
|
10327
|
+
* path. An empty string would produce a malformed `/users//animelist`
|
|
10328
|
+
* path, so it fails fast with a clear client-side message instead of an
|
|
10329
|
+
* upstream 404 or URL-parse error.
|
|
9443
10330
|
*
|
|
9444
10331
|
* @param normalized - The trimmed, lowercased username.
|
|
9445
10332
|
* @throws An {@link AniLinkValidationError} when the username is empty.
|
|
@@ -9488,7 +10375,7 @@ class MalUserOperation extends RestOperation {
|
|
|
9488
10375
|
* @param params - The profile read inputs; a {@link MalUserGetParams} carrying the username.
|
|
9489
10376
|
* @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
9490
10377
|
* @returns The requested {@link MalUser}.
|
|
9491
|
-
* @throws An `AniLinkAuthError` when
|
|
10378
|
+
* @throws An `AniLinkAuthError` when no access token is configured.
|
|
9492
10379
|
* @throws An {@link AniLinkValidationError} when `username` is empty or only whitespace.
|
|
9493
10380
|
* @throws A normalized `AniLinkError` when the request fails.
|
|
9494
10381
|
* @example
|
|
@@ -9510,17 +10397,17 @@ class MalUserOperation extends RestOperation {
|
|
|
9510
10397
|
return await this.execute(
|
|
9511
10398
|
normalized === "@me" ? "/users/@me" : "/users/{username}",
|
|
9512
10399
|
{
|
|
9513
|
-
//
|
|
9514
|
-
//
|
|
9515
|
-
//
|
|
9516
|
-
requiresAuth:
|
|
10400
|
+
// Only a bearer token can identify the authenticated user at
|
|
10401
|
+
// `@me`; a client ID alone cannot. Reject the request early,
|
|
10402
|
+
// as `me` does.
|
|
10403
|
+
requiresAuth: true,
|
|
9517
10404
|
transportOptions,
|
|
9518
10405
|
// `buildQueryString` skips undefined/null values, so the
|
|
9519
10406
|
// optional filters can be passed straight through.
|
|
9520
10407
|
query: {
|
|
9521
10408
|
fields: formatMalFields(fields)
|
|
9522
10409
|
},
|
|
9523
|
-
// The trimmed username, not the raw argument
|
|
10410
|
+
// The trimmed username, not the raw argument. Surrounding
|
|
9524
10411
|
// whitespace would otherwise be percent-encoded into the
|
|
9525
10412
|
// path and answered with a 404.
|
|
9526
10413
|
pathParams: normalized === "@me" ? void 0 : { username: normalized }
|
|
@@ -9530,7 +10417,13 @@ class MalUserOperation extends RestOperation {
|
|
|
9530
10417
|
/**
|
|
9531
10418
|
* {@link MalUserOperation.animeList} gets a user's anime list, one page at a time.
|
|
9532
10419
|
*
|
|
9533
|
-
* It calls `GET /users/{username}/animelist` through
|
|
10420
|
+
* It calls `GET /users/{username}/animelist` through
|
|
10421
|
+
* `RestOperation.execute` and returns a {@link MalUserAnimeListResponse}
|
|
10422
|
+
* page of `MalUserAnimeListEntry` entries shaped by
|
|
10423
|
+
* {@link MalRequestOptions.fields}. The facade exposes this method as
|
|
10424
|
+
* `MyAnimeListUserApi.animeList`. `username` accepts a user name or `@me`;
|
|
10425
|
+
* `@me` and private lists require an access token. The `@me` check ignores
|
|
10426
|
+
* case and surrounding whitespace.
|
|
9534
10427
|
*
|
|
9535
10428
|
* @param params - The anime-list read inputs; a {@link MalUserAnimeListParams} carrying the username plus the optional status, sort, and paging filters.
|
|
9536
10429
|
* @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -9557,8 +10450,8 @@ class MalUserOperation extends RestOperation {
|
|
|
9557
10450
|
return await this.execute(
|
|
9558
10451
|
normalized === "@me" ? "/users/@me/animelist" : "/users/{username}/animelist",
|
|
9559
10452
|
{
|
|
9560
|
-
//
|
|
9561
|
-
//
|
|
10453
|
+
// Only a bearer token can identify the authenticated user at
|
|
10454
|
+
// `@me`; a client ID alone cannot. Require one, as `me` does.
|
|
9562
10455
|
requiresAuth: normalized === "@me",
|
|
9563
10456
|
transportOptions,
|
|
9564
10457
|
// `buildQueryString` skips undefined/null values, so the
|
|
@@ -9570,7 +10463,7 @@ class MalUserOperation extends RestOperation {
|
|
|
9570
10463
|
limit,
|
|
9571
10464
|
offset
|
|
9572
10465
|
},
|
|
9573
|
-
// The trimmed username, not the raw argument
|
|
10466
|
+
// The trimmed username, not the raw argument. Surrounding
|
|
9574
10467
|
// whitespace would otherwise be percent-encoded into the
|
|
9575
10468
|
// path and answered with a 404.
|
|
9576
10469
|
pathParams: normalized === "@me" ? void 0 : { username: normalized }
|
|
@@ -9580,7 +10473,13 @@ class MalUserOperation extends RestOperation {
|
|
|
9580
10473
|
/**
|
|
9581
10474
|
* {@link MalUserOperation.mangaList} gets a user's manga list, one page at a time.
|
|
9582
10475
|
*
|
|
9583
|
-
* It calls `GET /users/{username}/mangalist` through
|
|
10476
|
+
* It calls `GET /users/{username}/mangalist` through
|
|
10477
|
+
* `RestOperation.execute` and returns a {@link MalUserMangaListResponse}
|
|
10478
|
+
* page of `MalUserMangaListEntry` entries shaped by
|
|
10479
|
+
* {@link MalRequestOptions.fields}. The facade exposes this method as
|
|
10480
|
+
* `MyAnimeListUserApi.mangaList`. `username` accepts a user name or `@me`;
|
|
10481
|
+
* `@me` and private lists require an access token. The `@me` check ignores
|
|
10482
|
+
* case and surrounding whitespace.
|
|
9584
10483
|
*
|
|
9585
10484
|
* @param params - The manga-list read inputs; a {@link MalUserMangaListParams} carrying the username plus the optional status, sort, and paging filters.
|
|
9586
10485
|
* @param options - Optional field selection and transport settings; a {@link MalRequestOptions} merged over the instance defaults.
|
|
@@ -9607,8 +10506,8 @@ class MalUserOperation extends RestOperation {
|
|
|
9607
10506
|
return await this.execute(
|
|
9608
10507
|
normalized === "@me" ? "/users/@me/mangalist" : "/users/{username}/mangalist",
|
|
9609
10508
|
{
|
|
9610
|
-
//
|
|
9611
|
-
//
|
|
10509
|
+
// Only a bearer token can identify the authenticated user at
|
|
10510
|
+
// `@me`; a client ID alone cannot. Require one, as `me` does.
|
|
9612
10511
|
requiresAuth: normalized === "@me",
|
|
9613
10512
|
transportOptions,
|
|
9614
10513
|
// `buildQueryString` skips undefined/null values, so the
|
|
@@ -9620,7 +10519,7 @@ class MalUserOperation extends RestOperation {
|
|
|
9620
10519
|
limit,
|
|
9621
10520
|
offset
|
|
9622
10521
|
},
|
|
9623
|
-
// The trimmed username, not the raw argument
|
|
10522
|
+
// The trimmed username, not the raw argument. Surrounding
|
|
9624
10523
|
// whitespace would otherwise be percent-encoded into the
|
|
9625
10524
|
// path and answered with a 404.
|
|
9626
10525
|
pathParams: normalized === "@me" ? void 0 : { username: normalized }
|
|
@@ -9629,180 +10528,60 @@ class MalUserOperation extends RestOperation {
|
|
|
9629
10528
|
}
|
|
9630
10529
|
}
|
|
9631
10530
|
|
|
9632
|
-
|
|
9633
|
-
|
|
9634
|
-
const DEFAULT_MAX_PAGES = 100;
|
|
9635
|
-
const DEFAULT_CONCURRENCY = 1;
|
|
9636
|
-
const safeCallback = (callback, name, onHookError, diagnostics, payload) => {
|
|
9637
|
-
safeInvoke(callback, name, onHookError, diagnostics, payload);
|
|
9638
|
-
};
|
|
9639
|
-
function extractHasMore(response, perPage) {
|
|
9640
|
-
return Array.isArray(response.data) && response.data.length >= perPage;
|
|
9641
|
-
}
|
|
9642
|
-
async function malPaginate(fetchPage, options) {
|
|
9643
|
-
const perPage = resolveCappedInt(options?.perPage, MAX_PER_PAGE, DEFAULT_PER_PAGE, "perPage");
|
|
9644
|
-
const startPage = resolvePositiveInt(options?.startPage, 1, "startPage");
|
|
9645
|
-
const maxPages = resolvePositiveInt(options?.maxPages, DEFAULT_MAX_PAGES, "maxPages");
|
|
9646
|
-
const concurrency = resolveCappedInt(
|
|
9647
|
-
options?.concurrency,
|
|
9648
|
-
MAX_CONCURRENCY,
|
|
9649
|
-
DEFAULT_CONCURRENCY,
|
|
9650
|
-
"concurrency"
|
|
9651
|
-
);
|
|
9652
|
-
const diagnostics = resolveDiagnosticsMode(options?.diagnostics);
|
|
9653
|
-
const { signal, dispose } = bridgeAbortSignal(options?.signal);
|
|
9654
|
-
try {
|
|
9655
|
-
const { responses, count, truncated } = await fetchWithLookAhead(
|
|
9656
|
-
(page) => fetchPage(page, perPage, signal),
|
|
9657
|
-
(response) => extractHasMore(response, perPage),
|
|
9658
|
-
startPage,
|
|
9659
|
-
maxPages,
|
|
9660
|
-
concurrency,
|
|
9661
|
-
signal
|
|
9662
|
-
);
|
|
9663
|
-
const items = [];
|
|
9664
|
-
const pages = [];
|
|
9665
|
-
for (const response of responses) {
|
|
9666
|
-
const raw = response.data;
|
|
9667
|
-
if (raw === void 0) {
|
|
9668
|
-
throw new AniLinkValidationError([
|
|
9669
|
-
'malPaginate: the page response has no "data" key. Check the fetchPage closure returns the raw MAL list response.'
|
|
9670
|
-
]);
|
|
9671
|
-
}
|
|
9672
|
-
const pageItems = Array.isArray(raw) ? raw : [];
|
|
9673
|
-
pages.push({ paging: response.paging, items: pageItems });
|
|
9674
|
-
items.push(...pageItems);
|
|
9675
|
-
safeCallback(options?.onPage, "onPage", options?.onHookError, diagnostics, {
|
|
9676
|
-
paging: response.paging,
|
|
9677
|
-
items: pageItems
|
|
9678
|
-
});
|
|
9679
|
-
}
|
|
9680
|
-
return { items, pages, pageCount: count, truncated };
|
|
9681
|
-
} finally {
|
|
9682
|
-
dispose();
|
|
9683
|
-
}
|
|
9684
|
-
}
|
|
9685
|
-
async function* malPaginatePages(fetchPage, options) {
|
|
9686
|
-
const perPage = resolveCappedInt(options?.perPage, MAX_PER_PAGE, DEFAULT_PER_PAGE, "perPage");
|
|
9687
|
-
const startPage = resolvePositiveInt(options?.startPage, 1, "startPage");
|
|
9688
|
-
const maxPages = resolvePositiveInt(options?.maxPages, DEFAULT_MAX_PAGES, "maxPages");
|
|
9689
|
-
const concurrency = resolveCappedInt(
|
|
9690
|
-
options?.concurrency,
|
|
9691
|
-
MAX_CONCURRENCY,
|
|
9692
|
-
DEFAULT_CONCURRENCY,
|
|
9693
|
-
"concurrency"
|
|
9694
|
-
);
|
|
9695
|
-
const diagnostics = resolveDiagnosticsMode(options?.diagnostics);
|
|
9696
|
-
for await (const response of streamNumericPages(
|
|
9697
|
-
fetchPage,
|
|
9698
|
-
// A short page is MAL's own end-of-list signal (its `paging.next` is
|
|
9699
|
-
// derived from the same offset math), so the traversal ends there
|
|
9700
|
-
// instead of launching a request the server has already said is
|
|
9701
|
-
// empty. A malformed `data` array ends the traversal the same way
|
|
9702
|
-
// instead of looping forever.
|
|
9703
|
-
(page) => !Array.isArray(page.data) || page.data.length < perPage,
|
|
9704
|
-
{ perPage, startPage, maxPages, concurrency, signal: options?.signal }
|
|
9705
|
-
)) {
|
|
9706
|
-
safeCallback(options?.onPage, "onPage", options?.onHookError, diagnostics, {
|
|
9707
|
-
paging: response.paging,
|
|
9708
|
-
items: Array.isArray(response.data) ? response.data : []
|
|
9709
|
-
});
|
|
9710
|
-
yield response;
|
|
9711
|
-
}
|
|
10531
|
+
function op(name, operationClass) {
|
|
10532
|
+
return { name, operationClass, methodName: name };
|
|
9712
10533
|
}
|
|
9713
|
-
|
|
9714
|
-
|
|
9715
|
-
|
|
9716
|
-
|
|
9717
|
-
|
|
9718
|
-
|
|
9719
|
-
|
|
9720
|
-
|
|
9721
|
-
|
|
9722
|
-
|
|
9723
|
-
|
|
9724
|
-
|
|
9725
|
-
|
|
9726
|
-
|
|
9727
|
-
|
|
9728
|
-
|
|
9729
|
-
|
|
9730
|
-
|
|
9731
|
-
|
|
9732
|
-
|
|
9733
|
-
|
|
9734
|
-
|
|
9735
|
-
|
|
9736
|
-
|
|
9737
|
-
|
|
9738
|
-
|
|
9739
|
-
|
|
9740
|
-
|
|
9741
|
-
}
|
|
9742
|
-
};
|
|
9743
|
-
const getMalAccessToken = (request) => requestMalToken(
|
|
9744
|
-
{
|
|
9745
|
-
client_id: request.clientId,
|
|
9746
|
-
code: request.code,
|
|
9747
|
-
code_verifier: request.codeVerifier,
|
|
9748
|
-
grant_type: "authorization_code",
|
|
9749
|
-
...request.clientSecret === void 0 ? {} : { client_secret: request.clientSecret }
|
|
9750
|
-
},
|
|
9751
|
-
request.options
|
|
9752
|
-
);
|
|
9753
|
-
const refreshMalAccessToken = (request) => requestMalToken(
|
|
9754
|
-
{
|
|
9755
|
-
client_id: request.clientId,
|
|
9756
|
-
grant_type: "refresh_token",
|
|
9757
|
-
refresh_token: request.refreshToken,
|
|
9758
|
-
...request.clientSecret === void 0 ? {} : { client_secret: request.clientSecret }
|
|
9759
|
-
},
|
|
9760
|
-
request.options
|
|
9761
|
-
);
|
|
9762
|
-
const getMalTokenExpiry = (response, now = Date.now()) => {
|
|
9763
|
-
const { expires_in } = response;
|
|
9764
|
-
if (!Number.isFinite(expires_in) || expires_in <= 0) {
|
|
9765
|
-
throw new TypeError(
|
|
9766
|
-
`Invalid expires_in ${expires_in}: token lifetime must be a finite, positive number of seconds.`
|
|
9767
|
-
);
|
|
9768
|
-
}
|
|
9769
|
-
return new Date(now + expires_in * 1e3);
|
|
10534
|
+
const MAL_OPERATION_REGISTRY = {
|
|
10535
|
+
anime: [
|
|
10536
|
+
op("get", MalAnimeOperation),
|
|
10537
|
+
op("search", MalAnimeOperation),
|
|
10538
|
+
op("seasonal", MalAnimeOperation),
|
|
10539
|
+
op("ranking", MalAnimeOperation),
|
|
10540
|
+
op("suggestions", MalAnimeOperation),
|
|
10541
|
+
op("updateMyListStatus", MalAnimeOperation),
|
|
10542
|
+
op("deleteFromList", MalAnimeOperation)
|
|
10543
|
+
],
|
|
10544
|
+
manga: [
|
|
10545
|
+
op("get", MalMangaOperation),
|
|
10546
|
+
op("search", MalMangaOperation),
|
|
10547
|
+
op("ranking", MalMangaOperation),
|
|
10548
|
+
op("updateMyListStatus", MalMangaOperation),
|
|
10549
|
+
op("deleteFromList", MalMangaOperation)
|
|
10550
|
+
],
|
|
10551
|
+
user: [
|
|
10552
|
+
op("me", MalUserOperation),
|
|
10553
|
+
op("get", MalUserOperation),
|
|
10554
|
+
op("animeList", MalUserOperation),
|
|
10555
|
+
op("mangaList", MalUserOperation)
|
|
10556
|
+
],
|
|
10557
|
+
forum: [
|
|
10558
|
+
op("boards", MalForumOperation),
|
|
10559
|
+
op("topics", MalForumOperation),
|
|
10560
|
+
op("topic", MalForumOperation)
|
|
10561
|
+
]
|
|
9770
10562
|
};
|
|
9771
10563
|
|
|
9772
|
-
const buildMalTokenRefresher = (options) => {
|
|
9773
|
-
|
|
9774
|
-
|
|
9775
|
-
|
|
9776
|
-
|
|
9777
|
-
});
|
|
9778
|
-
return new TokenRefresher({
|
|
9779
|
-
exchange,
|
|
9780
|
-
refreshToken: options.refreshToken,
|
|
9781
|
-
provider: { hookName: "malTokenRefresh", providerLabel: "MAL" },
|
|
9782
|
-
onTokenRefresh: options.onTokenRefresh,
|
|
9783
|
-
onTokenRefreshError: options.onTokenRefreshError,
|
|
9784
|
-
onHookError: options.onHookError,
|
|
9785
|
-
diagnostics: options.diagnostics,
|
|
9786
|
-
applyAccessToken: options.applyAccessToken
|
|
9787
|
-
});
|
|
9788
|
-
};
|
|
9789
|
-
const buildRefreshedAuth = (auth, accessToken) => {
|
|
9790
|
-
const headers = typeof auth === "object" && auth?.headers !== void 0 ? Object.fromEntries(
|
|
9791
|
-
Object.entries(auth.headers).filter(([key]) => key !== "X-MAL-CLIENT-ID")
|
|
9792
|
-
) : void 0;
|
|
9793
|
-
return {
|
|
9794
|
-
token: accessToken,
|
|
9795
|
-
headers: headers !== void 0 && Object.keys(headers).length > 0 ? headers : void 0
|
|
9796
|
-
};
|
|
9797
|
-
};
|
|
10564
|
+
const buildMalTokenRefresher = (options) => new TokenRefresher({
|
|
10565
|
+
descriptor: MAL_TOKEN_GRANT,
|
|
10566
|
+
...options
|
|
10567
|
+
});
|
|
10568
|
+
const buildRefreshedAuth = (auth, accessToken) => rewriteAuth(MAL_TOKEN_GRANT, auth, accessToken);
|
|
9798
10569
|
|
|
9799
10570
|
function buildMyAnimeListApi(credentials, stateOwner) {
|
|
9800
10571
|
const { auth, options } = resolveMalCredentials(credentials);
|
|
9801
10572
|
const sharedStateOwner = stateOwner ?? {};
|
|
9802
|
-
const
|
|
9803
|
-
const
|
|
9804
|
-
const
|
|
9805
|
-
|
|
10573
|
+
const groups = Object.keys(MAL_OPERATION_REGISTRY);
|
|
10574
|
+
const wired = [];
|
|
10575
|
+
for (const group of groups) {
|
|
10576
|
+
for (const entry of MAL_OPERATION_REGISTRY[group]) {
|
|
10577
|
+
const operationClass = entry.operationClass;
|
|
10578
|
+
wired.push({
|
|
10579
|
+
group,
|
|
10580
|
+
entry,
|
|
10581
|
+
instance: new operationClass(auth, options, sharedStateOwner)
|
|
10582
|
+
});
|
|
10583
|
+
}
|
|
10584
|
+
}
|
|
9806
10585
|
const refresher = isNonBlank(credentials?.refreshToken) && isNonBlank(credentials?.clientId) ? buildMalTokenRefresher({
|
|
9807
10586
|
clientId: credentials.clientId.trim(),
|
|
9808
10587
|
refreshToken: credentials.refreshToken.trim(),
|
|
@@ -9812,83 +10591,34 @@ function buildMyAnimeListApi(credentials, stateOwner) {
|
|
|
9812
10591
|
onHookError: credentials.onHookError,
|
|
9813
10592
|
diagnostics: credentials.diagnostics,
|
|
9814
10593
|
applyAccessToken: (accessToken) => {
|
|
9815
|
-
for (const
|
|
9816
|
-
|
|
9817
|
-
buildRefreshedAuth(operation.getAuth(), accessToken)
|
|
9818
|
-
);
|
|
10594
|
+
for (const { instance } of wired) {
|
|
10595
|
+
instance.updateAuth(buildRefreshedAuth(instance.getAuth(), accessToken));
|
|
9819
10596
|
}
|
|
9820
10597
|
}
|
|
9821
10598
|
}) : void 0;
|
|
9822
|
-
|
|
9823
|
-
|
|
9824
|
-
|
|
9825
|
-
|
|
9826
|
-
|
|
9827
|
-
|
|
9828
|
-
|
|
9829
|
-
suggestions: anime.suggestions.bind(anime),
|
|
9830
|
-
updateMyListStatus: anime.updateMyListStatus.bind(anime),
|
|
9831
|
-
deleteFromList: anime.deleteFromList.bind(anime)
|
|
9832
|
-
},
|
|
9833
|
-
manga: {
|
|
9834
|
-
get: manga.get.bind(manga),
|
|
9835
|
-
search: manga.search.bind(manga),
|
|
9836
|
-
ranking: manga.ranking.bind(manga),
|
|
9837
|
-
updateMyListStatus: manga.updateMyListStatus.bind(manga),
|
|
9838
|
-
deleteFromList: manga.deleteFromList.bind(manga)
|
|
9839
|
-
},
|
|
9840
|
-
user: {
|
|
9841
|
-
me: user.me.bind(user),
|
|
9842
|
-
get: user.get.bind(user),
|
|
9843
|
-
animeList: user.animeList.bind(user),
|
|
9844
|
-
mangaList: user.mangaList.bind(user)
|
|
9845
|
-
},
|
|
9846
|
-
forum: {
|
|
9847
|
-
boards: forum.boards.bind(forum),
|
|
9848
|
-
topics: forum.topics.bind(forum),
|
|
9849
|
-
topic: forum.topic.bind(forum)
|
|
9850
|
-
},
|
|
9851
|
-
// The pagination helpers are provider-owned pure functions over
|
|
9852
|
-
// the shared engine — no transport state to bind, so they are
|
|
9853
|
-
// exposed directly on both branches.
|
|
9854
|
-
paginate: malPaginate,
|
|
9855
|
-
paginatePages: malPaginatePages
|
|
9856
|
-
};
|
|
10599
|
+
const maybeWrap = refresher === void 0 ? (method) => method : (method) => (...args) => refresher.executeWithRefresh(() => method(...args));
|
|
10600
|
+
const groupMembers = Object.fromEntries(groups.map((group) => [group, {}]));
|
|
10601
|
+
for (const { group, entry, instance } of wired) {
|
|
10602
|
+
const method = instance[entry.methodName];
|
|
10603
|
+
groupMembers[group][entry.name] = maybeWrap(
|
|
10604
|
+
method.bind(instance)
|
|
10605
|
+
);
|
|
9857
10606
|
}
|
|
9858
|
-
const wrap = (method) => (...args) => refresher.executeWithRefresh(() => method(...args));
|
|
9859
10607
|
return {
|
|
9860
|
-
|
|
9861
|
-
|
|
9862
|
-
|
|
9863
|
-
|
|
9864
|
-
ranking: wrap(anime.ranking.bind(anime)),
|
|
9865
|
-
suggestions: wrap(anime.suggestions.bind(anime)),
|
|
9866
|
-
updateMyListStatus: wrap(anime.updateMyListStatus.bind(anime)),
|
|
9867
|
-
deleteFromList: wrap(anime.deleteFromList.bind(anime))
|
|
9868
|
-
},
|
|
9869
|
-
manga: {
|
|
9870
|
-
get: wrap(manga.get.bind(manga)),
|
|
9871
|
-
search: wrap(manga.search.bind(manga)),
|
|
9872
|
-
ranking: wrap(manga.ranking.bind(manga)),
|
|
9873
|
-
updateMyListStatus: wrap(manga.updateMyListStatus.bind(manga)),
|
|
9874
|
-
deleteFromList: wrap(manga.deleteFromList.bind(manga))
|
|
9875
|
-
},
|
|
9876
|
-
user: {
|
|
9877
|
-
me: wrap(user.me.bind(user)),
|
|
9878
|
-
get: wrap(user.get.bind(user)),
|
|
9879
|
-
animeList: wrap(user.animeList.bind(user)),
|
|
9880
|
-
mangaList: wrap(user.mangaList.bind(user))
|
|
9881
|
-
},
|
|
9882
|
-
forum: {
|
|
9883
|
-
boards: wrap(forum.boards.bind(forum)),
|
|
9884
|
-
topics: wrap(forum.topics.bind(forum)),
|
|
9885
|
-
topic: wrap(forum.topic.bind(forum))
|
|
10608
|
+
...groupMembers,
|
|
10609
|
+
auth: {
|
|
10610
|
+
buildAuthorizationUrl: buildMalAuthorizationUrl,
|
|
10611
|
+
exchangeCode: getMalAccessToken
|
|
9886
10612
|
},
|
|
10613
|
+
// The pagination helpers are provider-owned pure functions over
|
|
10614
|
+
// the shared engine. They need no transport state, so they are
|
|
10615
|
+
// exposed directly alongside the wired groups.
|
|
9887
10616
|
paginate: malPaginate,
|
|
9888
10617
|
paginatePages: malPaginatePages
|
|
9889
10618
|
};
|
|
9890
10619
|
}
|
|
9891
10620
|
|
|
10621
|
+
const registerProviderFactory = (factory, resolveCredentials, acceptsLegacyOptions = false) => Object.assign(factory, { resolveCredentials, acceptsLegacyOptions });
|
|
9892
10622
|
const buildAniListClient = (credentials, legacyOptions, stateOwner) => {
|
|
9893
10623
|
const resolved = resolveAniListCredentials(credentials);
|
|
9894
10624
|
return buildAniListApi(
|
|
@@ -9900,8 +10630,8 @@ const buildAniListClient = (credentials, legacyOptions, stateOwner) => {
|
|
|
9900
10630
|
};
|
|
9901
10631
|
const buildMalClient = (credentials, _legacyOptions, stateOwner) => buildMyAnimeListApi(credentials, stateOwner);
|
|
9902
10632
|
const PROVIDER_FACTORIES = {
|
|
9903
|
-
anilist: buildAniListClient,
|
|
9904
|
-
mal: buildMalClient
|
|
10633
|
+
anilist: registerProviderFactory(buildAniListClient, resolveAniListCredentials, true),
|
|
10634
|
+
mal: registerProviderFactory(buildMalClient, resolveMalCredentials)
|
|
9905
10635
|
};
|
|
9906
10636
|
function buildProviderClients(credentials = {}, legacyOptions) {
|
|
9907
10637
|
const clientHookError = credentials.onHookError;
|
|
@@ -9921,20 +10651,27 @@ function buildProviderClients(credentials = {}, legacyOptions) {
|
|
|
9921
10651
|
...needsDiagnostics ? { diagnostics: clientDiagnostics } : {}
|
|
9922
10652
|
};
|
|
9923
10653
|
};
|
|
9924
|
-
const
|
|
9925
|
-
const
|
|
9926
|
-
|
|
9927
|
-
|
|
9928
|
-
|
|
9929
|
-
|
|
9930
|
-
|
|
9931
|
-
|
|
9932
|
-
|
|
9933
|
-
|
|
9934
|
-
|
|
10654
|
+
const clients = {};
|
|
10655
|
+
const stateOwners = {};
|
|
10656
|
+
const responseCaches = {};
|
|
10657
|
+
for (const providerId of Object.keys(PROVIDER_FACTORIES)) {
|
|
10658
|
+
const factory = PROVIDER_FACTORIES[providerId];
|
|
10659
|
+
const slot = withDefaultHook(credentials[providerId]);
|
|
10660
|
+
const stateOwner = {};
|
|
10661
|
+
const client = factory(
|
|
10662
|
+
slot,
|
|
10663
|
+
factory.acceptsLegacyOptions ? legacyOptions : void 0,
|
|
10664
|
+
stateOwner
|
|
10665
|
+
);
|
|
10666
|
+
const effectiveOptions = factory.resolveCredentials(slot).options ?? (factory.acceptsLegacyOptions ? legacyOptions : void 0);
|
|
10667
|
+
clients[providerId] = client;
|
|
10668
|
+
stateOwners[providerId] = stateOwner;
|
|
10669
|
+
responseCaches[providerId] = effectiveOptions?.responseCache;
|
|
10670
|
+
}
|
|
10671
|
+
return { ...clients, stateOwners, responseCaches };
|
|
9935
10672
|
}
|
|
9936
10673
|
|
|
9937
|
-
const snapshotTransportState = (owner) => {
|
|
10674
|
+
const snapshotTransportState = (owner, responseCache) => {
|
|
9938
10675
|
const circuitScopes = peekCircuitStates(owner);
|
|
9939
10676
|
const circuit = circuitScopes === void 0 ? [] : Array.from(
|
|
9940
10677
|
circuitScopes,
|
|
@@ -9950,6 +10687,7 @@ const snapshotTransportState = (owner) => {
|
|
|
9950
10687
|
const deadlines = peekPaceDeadlines(owner);
|
|
9951
10688
|
const paceDeadlines = deadlines === void 0 ? [] : Array.from(deadlines, ([host, deadlineMs]) => Object.freeze({ host, deadlineMs }));
|
|
9952
10689
|
return Object.freeze({
|
|
10690
|
+
capturedAt: Date.now(),
|
|
9953
10691
|
circuit: Object.freeze(circuit),
|
|
9954
10692
|
...budget !== void 0 ? {
|
|
9955
10693
|
retryBudget: Object.freeze({
|
|
@@ -9957,21 +10695,24 @@ const snapshotTransportState = (owner) => {
|
|
|
9957
10695
|
windowEndsAt: budget.windowEndsAt
|
|
9958
10696
|
})
|
|
9959
10697
|
} : {},
|
|
9960
|
-
paceDeadlines: Object.freeze(paceDeadlines)
|
|
10698
|
+
paceDeadlines: Object.freeze(paceDeadlines),
|
|
10699
|
+
...responseCache !== void 0 ? { responseCache: responseCache.stats() } : {}
|
|
9961
10700
|
});
|
|
9962
10701
|
};
|
|
9963
10702
|
|
|
9964
10703
|
class AniLink {
|
|
9965
10704
|
/**
|
|
9966
|
-
* The AniList GraphQL API
|
|
10705
|
+
* The AniList GraphQL API, a {@link AniListApi} composed from the
|
|
9967
10706
|
* query, mutation, custom, and helper groups.
|
|
9968
10707
|
* @public
|
|
9969
10708
|
*/
|
|
9970
10709
|
anilist;
|
|
9971
10710
|
/** The MyAnimeList REST API methods, a {@link MyAnimeListApi} exposed under the `mal` namespace. */
|
|
9972
10711
|
mal;
|
|
9973
|
-
/** The per-provider state owners
|
|
10712
|
+
/** The per-provider state owners that hold each client's shared transport state (breaker, budget, pacing). */
|
|
9974
10713
|
stateOwners;
|
|
10714
|
+
/** The per-provider response caches resolved from the transport options, when enabled, for the cache-stats snapshot. */
|
|
10715
|
+
responseCaches;
|
|
9975
10716
|
/**
|
|
9976
10717
|
* Creates a new {@link AniLink} instance. The `authToken` parameter is optional and only
|
|
9977
10718
|
* required for authenticated queries and mutations; without it only public queries are
|
|
@@ -9981,9 +10722,9 @@ class AniLink {
|
|
|
9981
10722
|
* Alternatively, pass a per-provider {@link AniLinkCredentials} object: each provider
|
|
9982
10723
|
* owns its own credentials shape, and credentials given under one key are
|
|
9983
10724
|
* never applied to another provider's requests.
|
|
9984
|
-
* @param {string | AniLinkCredentials} [authToken] - The authentication token to use for AniList API requests, or a per-provider credentials object (`{ anilist?:
|
|
9985
|
-
* @param {AniLinkOptions} [options] - Transport settings scoped to this instance: `timeout`, `signal` cancellation, automatic retries under the default policy (`retry: false` opts out), `paceWithRateLimit` pacing (on by default), opt-in `circuitBreaker` fast-fail, the `onError`/`onRetry`/`onRequestStart`/`onResponse` observability hooks, and `exposeRawAxiosError` debugging. Options never leak between instances. Only valid when the first argument is a token string or omitted; combining a credentials object with a second argument throws, because the credentials form carries its own per-provider transport settings and a second argument would be silently dropped.
|
|
9986
|
-
* @throws {TypeError} When a per-provider credentials object is combined with a second `options` argument. The credentials form carries transport settings inside each provider slot, so the second argument would be silently ignored
|
|
10725
|
+
* @param {string | AniLinkCredentials} [authToken] - The authentication token to use for AniList API requests, or a per-provider credentials object (`{ anilist?: ..., mal?: ... }`).
|
|
10726
|
+
* @param {AniLinkOptions} [options] - Transport settings scoped to this instance: `timeout`, `signal` cancellation, automatic retries under the default policy (`retry: false` opts out), `paceWithRateLimit` pacing (on by default), opt-in `circuitBreaker` fast-fail, the `onError`/`onRetry`/`onRequestStart`/`onResponse` observability hooks, and `exposeRawAxiosError` debugging. In the legacy `(token, options)` form, these settings apply only to AniList. Configure MAL transport settings in the credentials-object form under `mal`. Options never leak between instances. Only valid when the first argument is a token string or omitted; combining a credentials object with a second argument throws, because the credentials form carries its own per-provider transport settings and a second argument would be silently dropped.
|
|
10727
|
+
* @throws {TypeError} When a per-provider credentials object is combined with a second `options` argument. The credentials form carries transport settings inside each provider slot, so the second argument would be silently ignored. The constructor rejects the ambiguous call instead.
|
|
9987
10728
|
* @public
|
|
9988
10729
|
* @example
|
|
9989
10730
|
* ```typescript
|
|
@@ -10022,23 +10763,32 @@ class AniLink {
|
|
|
10022
10763
|
this.anilist = clients.anilist;
|
|
10023
10764
|
this.mal = clients.mal;
|
|
10024
10765
|
this.stateOwners = clients.stateOwners;
|
|
10766
|
+
this.responseCaches = clients.responseCaches ?? {
|
|
10767
|
+
anilist: void 0,
|
|
10768
|
+
mal: void 0
|
|
10769
|
+
};
|
|
10025
10770
|
}
|
|
10026
10771
|
/**
|
|
10027
10772
|
* Returns a read-only, point-in-time snapshot of each provider client's
|
|
10028
|
-
* shared transport state
|
|
10029
|
-
* window,
|
|
10030
|
-
* state owner
|
|
10031
|
-
*
|
|
10032
|
-
*
|
|
10773
|
+
* shared transport state: the circuit-breaker scopes, retry-budget
|
|
10774
|
+
* window, rate-limit pacing deadlines keyed through that client's
|
|
10775
|
+
* state owner, and (when the provider's transport options enable a
|
|
10776
|
+
* response cache) the cache's live entry count and lifetime
|
|
10777
|
+
* hit/miss/expiration/eviction counters. Callers can check whether a
|
|
10778
|
+
* breaker is open, how many budget retries are spent, when pacing ends,
|
|
10779
|
+
* and how the cache performs
|
|
10780
|
+
* without wiring lifecycle hooks and without holding the
|
|
10781
|
+
* {@link ResponseCache} instance.
|
|
10033
10782
|
*
|
|
10034
10783
|
* Each provider's snapshot is built by {@link snapshotTransportState},
|
|
10035
|
-
* which owns the read-only contract
|
|
10036
|
-
* alias the live
|
|
10037
|
-
* the state it observes. Polling on a schedule is therefore safe
|
|
10784
|
+
* which owns the read-only contract. It returns deep-frozen copies that
|
|
10785
|
+
* never alias or mutate the live state, so polling on a schedule is safe
|
|
10038
10786
|
* alongside live traffic.
|
|
10039
10787
|
*
|
|
10040
10788
|
* @returns A frozen per-provider {@link TransportStateSnapshot} pair:
|
|
10041
|
-
* `{ anilist: {...}, mal: {...} }`.
|
|
10789
|
+
* `{ anilist: {...}, mal: {...} }`. Each snapshot carries `capturedAt`,
|
|
10790
|
+
* the epoch-millisecond build time, so a polled snapshot records when
|
|
10791
|
+
* it was taken.
|
|
10042
10792
|
* @example
|
|
10043
10793
|
* ```typescript
|
|
10044
10794
|
* const aniLink = new AniLink("token", {
|
|
@@ -10060,14 +10810,18 @@ class AniLink {
|
|
|
10060
10810
|
* for (const deadline of state.anilist.paceDeadlines) {
|
|
10061
10811
|
* console.log("pacing until", new Date(deadline.deadlineMs).toISOString(), "for", deadline.host);
|
|
10062
10812
|
* }
|
|
10813
|
+
* if (state.anilist.responseCache) {
|
|
10814
|
+
* console.log("cache entries:", state.anilist.responseCache.entries);
|
|
10815
|
+
* }
|
|
10063
10816
|
* ```
|
|
10064
10817
|
*/
|
|
10065
10818
|
getTransportState() {
|
|
10819
|
+
const caches = this.responseCaches ?? { anilist: void 0, mal: void 0 };
|
|
10066
10820
|
return Object.freeze({
|
|
10067
|
-
anilist: snapshotTransportState(this.stateOwners.anilist),
|
|
10068
|
-
mal: snapshotTransportState(this.stateOwners.mal)
|
|
10821
|
+
anilist: snapshotTransportState(this.stateOwners.anilist, caches.anilist),
|
|
10822
|
+
mal: snapshotTransportState(this.stateOwners.mal, caches.mal)
|
|
10069
10823
|
});
|
|
10070
10824
|
}
|
|
10071
10825
|
}
|
|
10072
10826
|
|
|
10073
|
-
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, crossLink, destroyCachedAgents, fuzzyDate, fuzzyDateInt, getAccessToken, getMalAccessToken, getMalTokenExpiry, getTokenExpiry, malPaginate, malPaginatePages, paginate, paginateChunks, paginatePages, refreshAccessToken, refreshMalAccessToken, snapshotTransportState };
|
|
10827
|
+
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, crossLink, destroyCachedAgents, fuzzyDate, fuzzyDateInt, getAccessToken, getMalAccessToken, getMalTokenExpiry, getTokenExpiry, malPaginate, malPaginatePages, mapExternalIds, paginate, paginateChunks, paginatePages, refreshAccessToken, refreshMalAccessToken, snapshotTransportState };
|