ani-client 2.2.1 → 2.4.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 +9 -191
- package/dist/index.d.mts +5 -7
- package/dist/index.d.ts +5 -7
- package/dist/index.js +139 -76
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +139 -76
- package/dist/index.mjs.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -8,17 +8,18 @@
|
|
|
8
8
|
[](LICENSE)
|
|
9
9
|
|
|
10
10
|
> A fully typed, zero-dependency client for the [AniList](https://anilist.co) GraphQL API.
|
|
11
|
-
> Supports Node.js
|
|
11
|
+
> Supports Node.js and modern browsers.
|
|
12
12
|
|
|
13
13
|
## Features
|
|
14
14
|
|
|
15
15
|
- **Zero dependencies** — uses the native `fetch` API
|
|
16
|
-
- **Universal** — Node.js ≥ 20,
|
|
16
|
+
- **Universal** — Node.js ≥ 20, and modern browsers
|
|
17
17
|
- **Dual format** — ships ESM + CJS with full `.d.ts` declarations
|
|
18
18
|
- **LRU cache** with TTL, stale-while-revalidate, and hit/miss stats
|
|
19
19
|
- **Rate-limit protection** with exponential backoff, retries, and custom strategies
|
|
20
20
|
- **Request deduplication** — concurrent identical queries share a single in-flight request
|
|
21
|
-
- **
|
|
21
|
+
- **Automatic batching (DataLoader)** — groups rapid, individual `getMedia(id)` calls within a 50ms window into a single batch query, saving API limits.
|
|
22
|
+
- **Batch queries** — manually fetch up to 50 media, characters, or staff in a single API call
|
|
22
23
|
- **Auto-pagination** — async iterator that yields items across all pages
|
|
23
24
|
- **AbortSignal support** — cancel globally or per-request via `withSignal()`
|
|
24
25
|
- **Injectable logger** — plug in `console`, pino, winston, or any compatible logger
|
|
@@ -52,180 +53,21 @@ const results = await client.searchMedia({
|
|
|
52
53
|
genres: ["Action"],
|
|
53
54
|
perPage: 10,
|
|
54
55
|
});
|
|
55
|
-
|
|
56
|
-
// Lookup by MyAnimeList ID
|
|
57
|
-
const fma = await client.getMediaByMalId(5114);
|
|
58
|
-
```
|
|
59
|
-
|
|
60
|
-
## Usage
|
|
61
|
-
|
|
62
|
-
### Caching
|
|
63
|
-
|
|
64
|
-
The client caches every response in memory by default. You can tune TTL, capacity, and enable stale-while-revalidate:
|
|
65
|
-
|
|
66
|
-
```ts
|
|
67
|
-
const client = new AniListClient({
|
|
68
|
-
cache: {
|
|
69
|
-
ttl: 1000 * 60 * 5, // 5 min TTL
|
|
70
|
-
maxSize: 200, // LRU capacity
|
|
71
|
-
staleWhileRevalidateMs: 60_000, // serve stale for 1 min after expiry
|
|
72
|
-
},
|
|
73
|
-
});
|
|
74
|
-
```
|
|
75
|
-
|
|
76
|
-
For distributed setups, swap to the built-in Redis adapter:
|
|
77
|
-
|
|
78
|
-
```ts
|
|
79
|
-
import { AniListClient, RedisCache } from "ani-client";
|
|
80
|
-
import { createClient } from "redis";
|
|
81
|
-
|
|
82
|
-
const redis = createClient();
|
|
83
|
-
await redis.connect();
|
|
84
|
-
|
|
85
|
-
const client = new AniListClient({
|
|
86
|
-
cacheAdapter: new RedisCache(redis),
|
|
87
|
-
});
|
|
88
56
|
```
|
|
89
57
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
The client is pre-configured to stay within AniList's 30 req/min limit. You can override the defaults and provide a custom retry strategy:
|
|
93
|
-
|
|
94
|
-
```ts
|
|
95
|
-
const client = new AniListClient({
|
|
96
|
-
rateLimit: {
|
|
97
|
-
maxRequests: 25,
|
|
98
|
-
windowMs: 60_000,
|
|
99
|
-
maxRetries: 3,
|
|
100
|
-
retryOnNetworkError: true,
|
|
101
|
-
retryStrategy: (attempt) => (attempt + 1) * 1000, // linear backoff
|
|
102
|
-
},
|
|
103
|
-
});
|
|
104
|
-
|
|
105
|
-
// Inspect the rate limit state after any request
|
|
106
|
-
console.log(client.rateLimitInfo);
|
|
107
|
-
// { remaining: 22, limit: 25, reset: 1741104000 }
|
|
108
|
-
```
|
|
109
|
-
|
|
110
|
-
### Batch Requests
|
|
111
|
-
|
|
112
|
-
Fetch multiple entries in a single API call (chunks of 50):
|
|
113
|
-
|
|
114
|
-
```ts
|
|
115
|
-
const anime = await client.getMediaBatch([1, 5, 6, 20]);
|
|
116
|
-
const characters = await client.getCharacterBatch([1, 2, 3]);
|
|
117
|
-
const staff = await client.getStaffBatch([95269, 95270]);
|
|
118
|
-
```
|
|
119
|
-
|
|
120
|
-
### Auto-Pagination
|
|
121
|
-
|
|
122
|
-
Use the built-in async iterator to walk through all pages automatically:
|
|
123
|
-
|
|
124
|
-
```ts
|
|
125
|
-
for await (const anime of client.paginate(
|
|
126
|
-
(page) => client.searchMedia({ query: "Gundam", page, perPage: 50 }),
|
|
127
|
-
5, // max 5 pages
|
|
128
|
-
)) {
|
|
129
|
-
console.log(anime.title.romaji);
|
|
130
|
-
}
|
|
131
|
-
```
|
|
132
|
-
|
|
133
|
-
### Request Cancellation
|
|
134
|
-
|
|
135
|
-
Scope any client instance to an `AbortSignal` for per-request cancellation:
|
|
136
|
-
|
|
137
|
-
```ts
|
|
138
|
-
const controller = new AbortController();
|
|
139
|
-
const scoped = client.withSignal(controller.signal);
|
|
140
|
-
|
|
141
|
-
setTimeout(() => controller.abort(), 3_000);
|
|
142
|
-
const anime = await scoped.getMedia(1); // cancelled after 3s
|
|
143
|
-
```
|
|
144
|
-
|
|
145
|
-
### Logging
|
|
146
|
-
|
|
147
|
-
Pass any `console`-compatible logger to trace requests and cache events:
|
|
148
|
-
|
|
149
|
-
```ts
|
|
150
|
-
const client = new AniListClient({ logger: console });
|
|
151
|
-
// debug: "API request" { variables: { id: 1 } }
|
|
152
|
-
// debug: "Request complete" { durationMs: 120 }
|
|
153
|
-
```
|
|
154
|
-
|
|
155
|
-
### Media Relationships
|
|
156
|
-
|
|
157
|
-
Paginated access to a media's characters and staff:
|
|
158
|
-
|
|
159
|
-
```ts
|
|
160
|
-
const characters = await client.getMediaCharacters(1, {
|
|
161
|
-
page: 1,
|
|
162
|
-
perPage: 25,
|
|
163
|
-
voiceActors: true,
|
|
164
|
-
});
|
|
165
|
-
|
|
166
|
-
const staff = await client.getMediaStaff(1, { page: 1, perPage: 25 });
|
|
167
|
-
```
|
|
168
|
-
|
|
169
|
-
### Users, Characters, Studios & More
|
|
170
|
-
|
|
171
|
-
```ts
|
|
172
|
-
const user = await client.getUser("AniList");
|
|
173
|
-
const favs = await client.getUserFavorites("AniList", { perPage: 50 });
|
|
174
|
-
const char = await client.getCharacter(1, { voiceActors: true });
|
|
175
|
-
const studio = await client.getStudio(21, { media: { perPage: 50 } });
|
|
176
|
-
const schedule = await client.getWeeklySchedule();
|
|
177
|
-
const review = await client.getReview(760);
|
|
178
|
-
```
|
|
179
|
-
|
|
180
|
-
### Error Handling
|
|
181
|
-
|
|
182
|
-
All API errors throw an `AniListError` with a `status` code and the raw GraphQL `errors` array:
|
|
183
|
-
|
|
184
|
-
```ts
|
|
185
|
-
import { AniListError } from "ani-client";
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
await client.getMedia(999999999);
|
|
189
|
-
} catch (e) {
|
|
190
|
-
if (e instanceof AniListError) {
|
|
191
|
-
console.error(e.message); // "Not Found"
|
|
192
|
-
console.error(e.status); // 404
|
|
193
|
-
console.error(e.errors); // raw GraphQL errors array
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
```
|
|
197
|
-
|
|
198
|
-
### Lifecycle Hooks
|
|
58
|
+
## Documentation
|
|
199
59
|
|
|
200
|
-
|
|
60
|
+
For full API reference, configuration options, caching, rate limiting, and advanced usage guides, please visit the official documentation:
|
|
201
61
|
|
|
202
|
-
|
|
203
|
-
const client = new AniListClient({
|
|
204
|
-
hooks: {
|
|
205
|
-
onRequest: (query, variables) => console.log("→", variables),
|
|
206
|
-
onResponse: (query, durationMs, fromCache) => console.log(`← ${durationMs}ms`),
|
|
207
|
-
onCacheHit: (key) => console.log("cache hit", key),
|
|
208
|
-
onRateLimit: (retryAfterMs) => console.warn(`rate limited, retrying in ${retryAfterMs}ms`),
|
|
209
|
-
onError: (error) => console.error(error.message),
|
|
210
|
-
},
|
|
211
|
-
});
|
|
212
|
-
```
|
|
62
|
+
**[📚 ani-client.js.org](https://ani-client.js.org)**
|
|
213
63
|
|
|
214
64
|
## Requirements
|
|
215
65
|
|
|
216
66
|
| Runtime | Version |
|
|
217
67
|
|----------|--------------------|
|
|
218
68
|
| Node.js | ≥ 22.13.0 |
|
|
219
|
-
| Bun | ≥ 1.0 |
|
|
220
|
-
| Deno | ≥ 1.28 |
|
|
221
69
|
| Browsers | `fetch` + `AbortController` required |
|
|
222
70
|
|
|
223
|
-
## Documentation
|
|
224
|
-
|
|
225
|
-
Full API reference, configuration options, and guides (caching, pagination, hooks, Redis, etc.):
|
|
226
|
-
|
|
227
|
-
**[ani-client.js.org](https://ani-client.js.org)**
|
|
228
|
-
|
|
229
71
|
## Community
|
|
230
72
|
|
|
231
73
|
- 💬 [Discord server](https://discord.gg/3P7twDurUD)
|
|
@@ -233,34 +75,10 @@ Full API reference, configuration options, and guides (caching, pagination, hook
|
|
|
233
75
|
|
|
234
76
|
## Contributing
|
|
235
77
|
|
|
236
|
-
Contributions are welcome
|
|
237
|
-
|
|
238
|
-
Before opening an issue or a pull request, please read:
|
|
78
|
+
Contributions are welcome! Before opening an issue or a pull request, please read:
|
|
239
79
|
- [CONTRIBUTING.md](CONTRIBUTING.md)
|
|
240
80
|
- [SECURITY.md](SECURITY.md)
|
|
241
81
|
|
|
242
|
-
This repository also includes GitHub issue templates and a pull request template to help keep reports and contributions consistent.
|
|
243
|
-
|
|
244
|
-
## Development
|
|
245
|
-
|
|
246
|
-
Quick commands for local development and CI checks:
|
|
247
|
-
|
|
248
|
-
```bash
|
|
249
|
-
pnpm install
|
|
250
|
-
pnpm run build # build dist
|
|
251
|
-
pnpm run typecheck # TypeScript strict checks
|
|
252
|
-
pnpm run lint # lint the codebase
|
|
253
|
-
pnpm test # run unit + integration tests
|
|
254
|
-
pnpm run docs:dev # run documentation site locally
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
Notes:
|
|
258
|
-
- `tsconfig.json` has `strict: true` enabled to enforce stricter TypeScript checks.
|
|
259
|
-
- Dependabot is configured to open weekly dependency PRs — you will review and merge them manually.
|
|
260
|
-
- CI should validate `build`, `typecheck`, `lint`, and `test` before merging PRs.
|
|
261
|
-
|
|
262
|
-
This repository also includes GitHub issue templates and a pull request template to help keep reports and contributions consistent.
|
|
263
|
-
|
|
264
82
|
## License
|
|
265
83
|
|
|
266
|
-
[MIT](LICENSE) © [gonzyui](https://
|
|
84
|
+
[MIT](LICENSE) © [gonzyui](https://gonzyuidev.xyz)
|
package/dist/index.d.mts
CHANGED
|
@@ -931,10 +931,10 @@ declare class NormalizedCache implements CacheAdapter {
|
|
|
931
931
|
private readonly swrMs;
|
|
932
932
|
private readonly queryStore;
|
|
933
933
|
private readonly entityStore;
|
|
934
|
+
private readonly refCount;
|
|
934
935
|
private _hits;
|
|
935
936
|
private _misses;
|
|
936
937
|
private _stales;
|
|
937
|
-
private gcTimeout?;
|
|
938
938
|
constructor(options?: CacheOptions);
|
|
939
939
|
static key(query: string, variables: Record<string, unknown>): string;
|
|
940
940
|
/** Normalizes a GraphQL response, extracting entities and returning a tree of references. */
|
|
@@ -947,6 +947,7 @@ declare class NormalizedCache implements CacheAdapter {
|
|
|
947
947
|
} | undefined;
|
|
948
948
|
get<T>(key: string): T | undefined;
|
|
949
949
|
set<T>(key: string, data: T): void;
|
|
950
|
+
private deleteQueryEntry;
|
|
950
951
|
delete(key: string): boolean;
|
|
951
952
|
clear(): void;
|
|
952
953
|
get size(): number;
|
|
@@ -956,12 +957,6 @@ declare class NormalizedCache implements CacheAdapter {
|
|
|
956
957
|
entitiesCount: number;
|
|
957
958
|
};
|
|
958
959
|
resetStats(): void;
|
|
959
|
-
private scheduleGc;
|
|
960
|
-
/**
|
|
961
|
-
* Garbage-collect orphaned entities that are no longer referenced by any query.
|
|
962
|
-
* Called automatically on LRU eviction to prevent unbounded entity store growth.
|
|
963
|
-
*/
|
|
964
|
-
gc(): number;
|
|
965
960
|
}
|
|
966
961
|
|
|
967
962
|
/** Cache performance statistics. */
|
|
@@ -1074,6 +1069,9 @@ declare class AniListClient implements ClientBase {
|
|
|
1074
1069
|
private readonly inFlight;
|
|
1075
1070
|
private _rateLimitInfo?;
|
|
1076
1071
|
private _lastRequestMeta?;
|
|
1072
|
+
private readonly mediaLoader;
|
|
1073
|
+
private readonly characterLoader;
|
|
1074
|
+
private readonly staffLoader;
|
|
1077
1075
|
constructor(options?: AniListClientOptions);
|
|
1078
1076
|
/**
|
|
1079
1077
|
* The current rate limit information from the last API response.
|
package/dist/index.d.ts
CHANGED
|
@@ -931,10 +931,10 @@ declare class NormalizedCache implements CacheAdapter {
|
|
|
931
931
|
private readonly swrMs;
|
|
932
932
|
private readonly queryStore;
|
|
933
933
|
private readonly entityStore;
|
|
934
|
+
private readonly refCount;
|
|
934
935
|
private _hits;
|
|
935
936
|
private _misses;
|
|
936
937
|
private _stales;
|
|
937
|
-
private gcTimeout?;
|
|
938
938
|
constructor(options?: CacheOptions);
|
|
939
939
|
static key(query: string, variables: Record<string, unknown>): string;
|
|
940
940
|
/** Normalizes a GraphQL response, extracting entities and returning a tree of references. */
|
|
@@ -947,6 +947,7 @@ declare class NormalizedCache implements CacheAdapter {
|
|
|
947
947
|
} | undefined;
|
|
948
948
|
get<T>(key: string): T | undefined;
|
|
949
949
|
set<T>(key: string, data: T): void;
|
|
950
|
+
private deleteQueryEntry;
|
|
950
951
|
delete(key: string): boolean;
|
|
951
952
|
clear(): void;
|
|
952
953
|
get size(): number;
|
|
@@ -956,12 +957,6 @@ declare class NormalizedCache implements CacheAdapter {
|
|
|
956
957
|
entitiesCount: number;
|
|
957
958
|
};
|
|
958
959
|
resetStats(): void;
|
|
959
|
-
private scheduleGc;
|
|
960
|
-
/**
|
|
961
|
-
* Garbage-collect orphaned entities that are no longer referenced by any query.
|
|
962
|
-
* Called automatically on LRU eviction to prevent unbounded entity store growth.
|
|
963
|
-
*/
|
|
964
|
-
gc(): number;
|
|
965
960
|
}
|
|
966
961
|
|
|
967
962
|
/** Cache performance statistics. */
|
|
@@ -1074,6 +1069,9 @@ declare class AniListClient implements ClientBase {
|
|
|
1074
1069
|
private readonly inFlight;
|
|
1075
1070
|
private _rateLimitInfo?;
|
|
1076
1071
|
private _lastRequestMeta?;
|
|
1072
|
+
private readonly mediaLoader;
|
|
1073
|
+
private readonly characterLoader;
|
|
1074
|
+
private readonly staffLoader;
|
|
1077
1075
|
constructor(options?: AniListClientOptions);
|
|
1078
1076
|
/**
|
|
1079
1077
|
* The current rate limit information from the last API response.
|
package/dist/index.js
CHANGED
|
@@ -1,5 +1,62 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// src/utils/dataloader.ts
|
|
4
|
+
var BatchLoader = class {
|
|
5
|
+
constructor(batchFetch, maxWaitMs = 50) {
|
|
6
|
+
this.batchFetch = batchFetch;
|
|
7
|
+
this.maxWaitMs = maxWaitMs;
|
|
8
|
+
}
|
|
9
|
+
queue = /* @__PURE__ */ new Map();
|
|
10
|
+
timeout = null;
|
|
11
|
+
/**
|
|
12
|
+
* Queue an ID to be fetched in the next batch.
|
|
13
|
+
* Returns a Promise that resolves when the batch request completes.
|
|
14
|
+
*/
|
|
15
|
+
async load(id) {
|
|
16
|
+
return new Promise((resolve, reject) => {
|
|
17
|
+
let callbacks = this.queue.get(id);
|
|
18
|
+
if (!callbacks) {
|
|
19
|
+
callbacks = [];
|
|
20
|
+
this.queue.set(id, callbacks);
|
|
21
|
+
}
|
|
22
|
+
callbacks.push({ resolve, reject });
|
|
23
|
+
if (this.timeout === null) {
|
|
24
|
+
this.timeout = setTimeout(() => this.dispatch(), this.maxWaitMs);
|
|
25
|
+
if (typeof this.timeout.unref === "function") {
|
|
26
|
+
this.timeout.unref();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
async dispatch() {
|
|
32
|
+
this.timeout = null;
|
|
33
|
+
if (this.queue.size === 0) return;
|
|
34
|
+
const currentQueue = this.queue;
|
|
35
|
+
this.queue = /* @__PURE__ */ new Map();
|
|
36
|
+
const ids = Array.from(currentQueue.keys());
|
|
37
|
+
try {
|
|
38
|
+
const results = await this.batchFetch(ids);
|
|
39
|
+
const resultMap = /* @__PURE__ */ new Map();
|
|
40
|
+
for (const item of results) {
|
|
41
|
+
resultMap.set(item.id, item);
|
|
42
|
+
}
|
|
43
|
+
for (const [id, callbacks] of currentQueue.entries()) {
|
|
44
|
+
const result = resultMap.get(id);
|
|
45
|
+
if (result) {
|
|
46
|
+
for (const cb of callbacks) cb.resolve(result);
|
|
47
|
+
} else {
|
|
48
|
+
const err = new Error(`Item with ID ${id} not found in batch response`);
|
|
49
|
+
for (const cb of callbacks) cb.reject(err);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
for (const callbacks of currentQueue.values()) {
|
|
54
|
+
for (const cb of callbacks) cb.reject(err);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
};
|
|
59
|
+
|
|
3
60
|
// src/utils/markdown.ts
|
|
4
61
|
function isSafeUrl(url) {
|
|
5
62
|
return /^https?:\/\//i.test(url);
|
|
@@ -117,8 +174,17 @@ function validateIds(ids, label = "id") {
|
|
|
117
174
|
function sortObjectKeys(obj) {
|
|
118
175
|
if (obj === null || typeof obj !== "object") return obj;
|
|
119
176
|
if (Array.isArray(obj)) return obj.map(sortObjectKeys);
|
|
177
|
+
const keys = Object.keys(obj);
|
|
178
|
+
if (keys.length === 0) return obj;
|
|
179
|
+
if (keys.length === 1) {
|
|
180
|
+
const key = keys[0];
|
|
181
|
+
const val = obj[key];
|
|
182
|
+
const sortedVal = sortObjectKeys(val);
|
|
183
|
+
if (val === sortedVal) return obj;
|
|
184
|
+
return { [key]: sortedVal };
|
|
185
|
+
}
|
|
120
186
|
const sorted = {};
|
|
121
|
-
for (const key of
|
|
187
|
+
for (const key of keys.sort()) {
|
|
122
188
|
sorted[key] = sortObjectKeys(obj[key]);
|
|
123
189
|
}
|
|
124
190
|
return sorted;
|
|
@@ -132,10 +198,10 @@ var NormalizedCache = class {
|
|
|
132
198
|
swrMs;
|
|
133
199
|
queryStore = /* @__PURE__ */ new Map();
|
|
134
200
|
entityStore = /* @__PURE__ */ new Map();
|
|
201
|
+
refCount = /* @__PURE__ */ new Map();
|
|
135
202
|
_hits = 0;
|
|
136
203
|
_misses = 0;
|
|
137
204
|
_stales = 0;
|
|
138
|
-
gcTimeout;
|
|
139
205
|
constructor(options = {}) {
|
|
140
206
|
this.ttl = options.ttl ?? 24 * 60 * 60 * 1e3;
|
|
141
207
|
this.maxSize = options.maxSize ?? 500;
|
|
@@ -147,11 +213,11 @@ var NormalizedCache = class {
|
|
|
147
213
|
return `${normalized}|${JSON.stringify(sortObjectKeys(variables))}`;
|
|
148
214
|
}
|
|
149
215
|
/** Normalizes a GraphQL response, extracting entities and returning a tree of references. */
|
|
150
|
-
normalize(data, seen = /* @__PURE__ */ new WeakSet()) {
|
|
216
|
+
normalize(data, refsOut, seen = /* @__PURE__ */ new WeakSet()) {
|
|
151
217
|
if (Array.isArray(data)) {
|
|
152
218
|
if (seen.has(data)) return null;
|
|
153
219
|
seen.add(data);
|
|
154
|
-
return data.map((item) => this.normalize(item, seen));
|
|
220
|
+
return data.map((item) => this.normalize(item, refsOut, seen));
|
|
155
221
|
}
|
|
156
222
|
if (data !== null && typeof data === "object") {
|
|
157
223
|
if (seen.has(data)) return null;
|
|
@@ -159,17 +225,22 @@ var NormalizedCache = class {
|
|
|
159
225
|
const obj = data;
|
|
160
226
|
if (typeof obj.__typename === "string" && (typeof obj.id === "number" || typeof obj.id === "string")) {
|
|
161
227
|
const ref = `${obj.__typename}:${obj.id}`;
|
|
228
|
+
refsOut.add(ref);
|
|
162
229
|
const normalizedObj = {};
|
|
163
|
-
for (const
|
|
164
|
-
|
|
230
|
+
for (const k in obj) {
|
|
231
|
+
if (Object.hasOwn(obj, k)) {
|
|
232
|
+
normalizedObj[k] = this.normalize(obj[k], refsOut, seen);
|
|
233
|
+
}
|
|
165
234
|
}
|
|
166
235
|
const existing = this.entityStore.get(ref) || {};
|
|
167
236
|
this.entityStore.set(ref, { ...existing, ...normalizedObj });
|
|
168
237
|
return { __ref: ref };
|
|
169
238
|
}
|
|
170
239
|
const result = {};
|
|
171
|
-
for (const
|
|
172
|
-
|
|
240
|
+
for (const k in obj) {
|
|
241
|
+
if (Object.hasOwn(obj, k)) {
|
|
242
|
+
result[k] = this.normalize(obj[k], refsOut, seen);
|
|
243
|
+
}
|
|
173
244
|
}
|
|
174
245
|
return result;
|
|
175
246
|
}
|
|
@@ -195,10 +266,12 @@ var NormalizedCache = class {
|
|
|
195
266
|
return result2;
|
|
196
267
|
}
|
|
197
268
|
const result = {};
|
|
198
|
-
for (const
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
269
|
+
for (const k in obj) {
|
|
270
|
+
if (Object.hasOwn(obj, k)) {
|
|
271
|
+
const denormalized = this.denormalize(obj[k], seen);
|
|
272
|
+
if (denormalized === void 0) return void 0;
|
|
273
|
+
result[k] = denormalized;
|
|
274
|
+
}
|
|
202
275
|
}
|
|
203
276
|
return result;
|
|
204
277
|
}
|
|
@@ -217,14 +290,14 @@ var NormalizedCache = class {
|
|
|
217
290
|
if (this.swrMs > 0 && now <= entry.expiresAt + this.swrMs) {
|
|
218
291
|
isStale = true;
|
|
219
292
|
} else {
|
|
220
|
-
this.
|
|
293
|
+
this.deleteQueryEntry(key, entry);
|
|
221
294
|
this._misses++;
|
|
222
295
|
return void 0;
|
|
223
296
|
}
|
|
224
297
|
}
|
|
225
298
|
const denormalized = this.denormalize(entry.data);
|
|
226
299
|
if (denormalized === void 0) {
|
|
227
|
-
this.
|
|
300
|
+
this.deleteQueryEntry(key, entry);
|
|
228
301
|
this._misses++;
|
|
229
302
|
return void 0;
|
|
230
303
|
}
|
|
@@ -243,27 +316,46 @@ var NormalizedCache = class {
|
|
|
243
316
|
}
|
|
244
317
|
set(key, data) {
|
|
245
318
|
if (!this.enabled) return;
|
|
246
|
-
const
|
|
247
|
-
this.
|
|
319
|
+
const refs = /* @__PURE__ */ new Set();
|
|
320
|
+
const normalizedData = this.normalize(data, refs);
|
|
321
|
+
const existing = this.queryStore.get(key);
|
|
322
|
+
if (existing) {
|
|
323
|
+
this.deleteQueryEntry(key, existing);
|
|
324
|
+
}
|
|
248
325
|
if (this.maxSize > 0 && this.queryStore.size >= this.maxSize) {
|
|
249
326
|
const firstKey = this.queryStore.keys().next().value;
|
|
250
327
|
if (firstKey !== void 0) {
|
|
251
|
-
this.queryStore.
|
|
328
|
+
const firstEntry = this.queryStore.get(firstKey);
|
|
329
|
+
if (firstEntry) this.deleteQueryEntry(firstKey, firstEntry);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
for (const ref of refs) {
|
|
333
|
+
this.refCount.set(ref, (this.refCount.get(ref) ?? 0) + 1);
|
|
334
|
+
}
|
|
335
|
+
this.queryStore.set(key, { data: normalizedData, refs, expiresAt: Date.now() + this.ttl });
|
|
336
|
+
}
|
|
337
|
+
deleteQueryEntry(key, entry) {
|
|
338
|
+
this.queryStore.delete(key);
|
|
339
|
+
for (const ref of entry.refs) {
|
|
340
|
+
const count = (this.refCount.get(ref) ?? 0) - 1;
|
|
341
|
+
if (count <= 0) {
|
|
342
|
+
this.refCount.delete(ref);
|
|
343
|
+
this.entityStore.delete(ref);
|
|
344
|
+
} else {
|
|
345
|
+
this.refCount.set(ref, count);
|
|
252
346
|
}
|
|
253
|
-
this.scheduleGc();
|
|
254
347
|
}
|
|
255
|
-
this.queryStore.set(key, { data: normalizedData, expiresAt: Date.now() + this.ttl });
|
|
256
348
|
}
|
|
257
349
|
delete(key) {
|
|
258
|
-
|
|
350
|
+
const entry = this.queryStore.get(key);
|
|
351
|
+
if (!entry) return false;
|
|
352
|
+
this.deleteQueryEntry(key, entry);
|
|
353
|
+
return true;
|
|
259
354
|
}
|
|
260
355
|
clear() {
|
|
261
|
-
if (this.gcTimeout) {
|
|
262
|
-
clearTimeout(this.gcTimeout);
|
|
263
|
-
this.gcTimeout = void 0;
|
|
264
|
-
}
|
|
265
356
|
this.queryStore.clear();
|
|
266
357
|
this.entityStore.clear();
|
|
358
|
+
this.refCount.clear();
|
|
267
359
|
this._hits = 0;
|
|
268
360
|
this._misses = 0;
|
|
269
361
|
this._stales = 0;
|
|
@@ -280,7 +372,9 @@ var NormalizedCache = class {
|
|
|
280
372
|
for (const key of this.queryStore.keys()) {
|
|
281
373
|
if (test(key)) toDelete.push(key);
|
|
282
374
|
}
|
|
283
|
-
for (const key of toDelete)
|
|
375
|
+
for (const key of toDelete) {
|
|
376
|
+
this.delete(key);
|
|
377
|
+
}
|
|
284
378
|
return toDelete.length;
|
|
285
379
|
}
|
|
286
380
|
get stats() {
|
|
@@ -298,53 +392,6 @@ var NormalizedCache = class {
|
|
|
298
392
|
this._misses = 0;
|
|
299
393
|
this._stales = 0;
|
|
300
394
|
}
|
|
301
|
-
scheduleGc() {
|
|
302
|
-
if (this.gcTimeout) return;
|
|
303
|
-
this.gcTimeout = setTimeout(() => {
|
|
304
|
-
this.gc();
|
|
305
|
-
this.gcTimeout = void 0;
|
|
306
|
-
}, 500);
|
|
307
|
-
if (typeof this.gcTimeout.unref === "function") {
|
|
308
|
-
this.gcTimeout.unref();
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
/**
|
|
312
|
-
* Garbage-collect orphaned entities that are no longer referenced by any query.
|
|
313
|
-
* Called automatically on LRU eviction to prevent unbounded entity store growth.
|
|
314
|
-
*/
|
|
315
|
-
gc() {
|
|
316
|
-
const referencedRefs = /* @__PURE__ */ new Set();
|
|
317
|
-
const collectRefs = (data) => {
|
|
318
|
-
if (Array.isArray(data)) {
|
|
319
|
-
for (const item of data) collectRefs(item);
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
if (data !== null && typeof data === "object") {
|
|
323
|
-
const obj = data;
|
|
324
|
-
if (typeof obj.__ref === "string") {
|
|
325
|
-
referencedRefs.add(obj.__ref);
|
|
326
|
-
const entity = this.entityStore.get(obj.__ref);
|
|
327
|
-
if (entity && !referencedRefs.has(`_visited:${obj.__ref}`)) {
|
|
328
|
-
referencedRefs.add(`_visited:${obj.__ref}`);
|
|
329
|
-
collectRefs(entity);
|
|
330
|
-
}
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
for (const v of Object.values(obj)) collectRefs(v);
|
|
334
|
-
}
|
|
335
|
-
};
|
|
336
|
-
for (const entry of this.queryStore.values()) {
|
|
337
|
-
collectRefs(entry.data);
|
|
338
|
-
}
|
|
339
|
-
let removed = 0;
|
|
340
|
-
for (const ref of this.entityStore.keys()) {
|
|
341
|
-
if (!referencedRefs.has(ref)) {
|
|
342
|
-
this.entityStore.delete(ref);
|
|
343
|
-
removed++;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
return removed;
|
|
347
|
-
}
|
|
348
395
|
};
|
|
349
396
|
|
|
350
397
|
// src/cache/index.ts
|
|
@@ -2541,7 +2588,7 @@ function mapFavorites(fav) {
|
|
|
2541
2588
|
|
|
2542
2589
|
// src/client/index.ts
|
|
2543
2590
|
var DEFAULT_API_URL = "https://graphql.anilist.co";
|
|
2544
|
-
var LIB_VERSION = "2.
|
|
2591
|
+
var LIB_VERSION = "2.4.0" ;
|
|
2545
2592
|
var AniListClient = class {
|
|
2546
2593
|
apiUrl;
|
|
2547
2594
|
headers;
|
|
@@ -2553,6 +2600,9 @@ var AniListClient = class {
|
|
|
2553
2600
|
inFlight = /* @__PURE__ */ new Map();
|
|
2554
2601
|
_rateLimitInfo;
|
|
2555
2602
|
_lastRequestMeta;
|
|
2603
|
+
mediaLoader;
|
|
2604
|
+
characterLoader;
|
|
2605
|
+
staffLoader;
|
|
2556
2606
|
constructor(options = {}) {
|
|
2557
2607
|
this.apiUrl = options.apiUrl ?? DEFAULT_API_URL;
|
|
2558
2608
|
this.headers = {
|
|
@@ -2568,6 +2618,9 @@ var AniListClient = class {
|
|
|
2568
2618
|
this.hooks = options.hooks ?? {};
|
|
2569
2619
|
this.logger = options.logger;
|
|
2570
2620
|
this.signal = options.signal;
|
|
2621
|
+
this.mediaLoader = new BatchLoader((ids) => this.getMediaBatch(ids), 50);
|
|
2622
|
+
this.characterLoader = new BatchLoader((ids) => this.getCharacterBatch(ids), 50);
|
|
2623
|
+
this.staffLoader = new BatchLoader((ids) => this.getStaffBatch(ids), 50);
|
|
2571
2624
|
}
|
|
2572
2625
|
/**
|
|
2573
2626
|
* The current rate limit information from the last API response.
|
|
@@ -2698,6 +2751,9 @@ var AniListClient = class {
|
|
|
2698
2751
|
* @param include - Optional related data to include
|
|
2699
2752
|
*/
|
|
2700
2753
|
async getMedia(id, include) {
|
|
2754
|
+
if (!include) {
|
|
2755
|
+
return this.mediaLoader.load(id);
|
|
2756
|
+
}
|
|
2701
2757
|
return getMedia(this, id, include);
|
|
2702
2758
|
}
|
|
2703
2759
|
async getMediaCharacters(mediaId, options = {}) {
|
|
@@ -2766,6 +2822,9 @@ var AniListClient = class {
|
|
|
2766
2822
|
}
|
|
2767
2823
|
/** Fetch a character by AniList ID. Pass `{ voiceActors: true }` to include VA data. */
|
|
2768
2824
|
async getCharacter(id, include) {
|
|
2825
|
+
if (!include) {
|
|
2826
|
+
return this.characterLoader.load(id);
|
|
2827
|
+
}
|
|
2769
2828
|
return getCharacter(this, id, include);
|
|
2770
2829
|
}
|
|
2771
2830
|
/** Search for characters by name. */
|
|
@@ -2774,6 +2833,9 @@ var AniListClient = class {
|
|
|
2774
2833
|
}
|
|
2775
2834
|
/** Fetch a staff member by AniList ID. Pass `{ media: true }` or `{ media: { perPage } }` for media credits. */
|
|
2776
2835
|
async getStaff(id, include) {
|
|
2836
|
+
if (!include) {
|
|
2837
|
+
return this.staffLoader.load(id);
|
|
2838
|
+
}
|
|
2777
2839
|
return getStaff(this, id, include);
|
|
2778
2840
|
}
|
|
2779
2841
|
/** Search for staff (voice actors, directors, etc.). */
|
|
@@ -2893,7 +2955,7 @@ var AniListClient = class {
|
|
|
2893
2955
|
if (ids.length === 0) return [];
|
|
2894
2956
|
validateIds(ids, "mediaId");
|
|
2895
2957
|
const [singleMediaId] = ids;
|
|
2896
|
-
if (ids.length === 1 && singleMediaId !== void 0) return [await
|
|
2958
|
+
if (ids.length === 1 && singleMediaId !== void 0) return [await getMedia(this, singleMediaId)];
|
|
2897
2959
|
return this.executeBatch(ids, buildBatchMediaQuery, "m");
|
|
2898
2960
|
}
|
|
2899
2961
|
/** Fetch multiple characters in a single API request. */
|
|
@@ -2901,7 +2963,8 @@ var AniListClient = class {
|
|
|
2901
2963
|
if (ids.length === 0) return [];
|
|
2902
2964
|
validateIds(ids, "characterId");
|
|
2903
2965
|
const [singleCharId] = ids;
|
|
2904
|
-
if (ids.length === 1 && singleCharId !== void 0)
|
|
2966
|
+
if (ids.length === 1 && singleCharId !== void 0)
|
|
2967
|
+
return [await getCharacter(this, singleCharId)];
|
|
2905
2968
|
return this.executeBatch(ids, buildBatchCharacterQuery, "c");
|
|
2906
2969
|
}
|
|
2907
2970
|
/** Fetch multiple staff members in a single API request. */
|
|
@@ -2909,7 +2972,7 @@ var AniListClient = class {
|
|
|
2909
2972
|
if (ids.length === 0) return [];
|
|
2910
2973
|
validateIds(ids, "staffId");
|
|
2911
2974
|
const [singleStaffId] = ids;
|
|
2912
|
-
if (ids.length === 1 && singleStaffId !== void 0) return [await
|
|
2975
|
+
if (ids.length === 1 && singleStaffId !== void 0) return [await getStaff(this, singleStaffId)];
|
|
2913
2976
|
return this.executeBatch(ids, buildBatchStaffQuery, "s");
|
|
2914
2977
|
}
|
|
2915
2978
|
/** @internal */
|