ani-client 2.2.1 → 2.3.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 CHANGED
@@ -8,12 +8,12 @@
8
8
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
9
9
 
10
10
  > A fully typed, zero-dependency client for the [AniList](https://anilist.co) GraphQL API.
11
- > Supports Node.js, Bun, Deno, and modern browsers.
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, Bun, Deno, and modern browsers
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
@@ -52,180 +52,21 @@ const results = await client.searchMedia({
52
52
  genres: ["Action"],
53
53
  perPage: 10,
54
54
  });
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
55
  ```
89
56
 
90
- ### Rate Limiting
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
57
+ ## Documentation
199
58
 
200
- Intercept requests, responses, cache events, and errors:
59
+ For full API reference, configuration options, caching, rate limiting, and advanced usage guides, please visit the official documentation:
201
60
 
202
- ```ts
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
- ```
61
+ **[📚 ani-client.js.org](https://ani-client.js.org)**
213
62
 
214
63
  ## Requirements
215
64
 
216
65
  | Runtime | Version |
217
66
  |----------|--------------------|
218
67
  | Node.js | ≥ 22.13.0 |
219
- | Bun | ≥ 1.0 |
220
- | Deno | ≥ 1.28 |
221
68
  | Browsers | `fetch` + `AbortController` required |
222
69
 
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
70
  ## Community
230
71
 
231
72
  - 💬 [Discord server](https://discord.gg/3P7twDurUD)
@@ -233,34 +74,10 @@ Full API reference, configuration options, and guides (caching, pagination, hook
233
74
 
234
75
  ## Contributing
235
76
 
236
- Contributions are welcome.
237
-
238
- Before opening an issue or a pull request, please read:
77
+ Contributions are welcome! Before opening an issue or a pull request, please read:
239
78
  - [CONTRIBUTING.md](CONTRIBUTING.md)
240
79
  - [SECURITY.md](SECURITY.md)
241
80
 
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
81
  ## License
265
82
 
266
- [MIT](LICENSE) © [gonzyui](https://github.com/gonzyui)
83
+ [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. */
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. */
package/dist/index.js CHANGED
@@ -117,8 +117,17 @@ function validateIds(ids, label = "id") {
117
117
  function sortObjectKeys(obj) {
118
118
  if (obj === null || typeof obj !== "object") return obj;
119
119
  if (Array.isArray(obj)) return obj.map(sortObjectKeys);
120
+ const keys = Object.keys(obj);
121
+ if (keys.length === 0) return obj;
122
+ if (keys.length === 1) {
123
+ const key = keys[0];
124
+ const val = obj[key];
125
+ const sortedVal = sortObjectKeys(val);
126
+ if (val === sortedVal) return obj;
127
+ return { [key]: sortedVal };
128
+ }
120
129
  const sorted = {};
121
- for (const key of Object.keys(obj).sort()) {
130
+ for (const key of keys.sort()) {
122
131
  sorted[key] = sortObjectKeys(obj[key]);
123
132
  }
124
133
  return sorted;
@@ -132,10 +141,10 @@ var NormalizedCache = class {
132
141
  swrMs;
133
142
  queryStore = /* @__PURE__ */ new Map();
134
143
  entityStore = /* @__PURE__ */ new Map();
144
+ refCount = /* @__PURE__ */ new Map();
135
145
  _hits = 0;
136
146
  _misses = 0;
137
147
  _stales = 0;
138
- gcTimeout;
139
148
  constructor(options = {}) {
140
149
  this.ttl = options.ttl ?? 24 * 60 * 60 * 1e3;
141
150
  this.maxSize = options.maxSize ?? 500;
@@ -147,11 +156,11 @@ var NormalizedCache = class {
147
156
  return `${normalized}|${JSON.stringify(sortObjectKeys(variables))}`;
148
157
  }
149
158
  /** Normalizes a GraphQL response, extracting entities and returning a tree of references. */
150
- normalize(data, seen = /* @__PURE__ */ new WeakSet()) {
159
+ normalize(data, refsOut, seen = /* @__PURE__ */ new WeakSet()) {
151
160
  if (Array.isArray(data)) {
152
161
  if (seen.has(data)) return null;
153
162
  seen.add(data);
154
- return data.map((item) => this.normalize(item, seen));
163
+ return data.map((item) => this.normalize(item, refsOut, seen));
155
164
  }
156
165
  if (data !== null && typeof data === "object") {
157
166
  if (seen.has(data)) return null;
@@ -159,17 +168,22 @@ var NormalizedCache = class {
159
168
  const obj = data;
160
169
  if (typeof obj.__typename === "string" && (typeof obj.id === "number" || typeof obj.id === "string")) {
161
170
  const ref = `${obj.__typename}:${obj.id}`;
171
+ refsOut.add(ref);
162
172
  const normalizedObj = {};
163
- for (const [k, v] of Object.entries(obj)) {
164
- normalizedObj[k] = this.normalize(v, seen);
173
+ for (const k in obj) {
174
+ if (Object.hasOwn(obj, k)) {
175
+ normalizedObj[k] = this.normalize(obj[k], refsOut, seen);
176
+ }
165
177
  }
166
178
  const existing = this.entityStore.get(ref) || {};
167
179
  this.entityStore.set(ref, { ...existing, ...normalizedObj });
168
180
  return { __ref: ref };
169
181
  }
170
182
  const result = {};
171
- for (const [k, v] of Object.entries(obj)) {
172
- result[k] = this.normalize(v, seen);
183
+ for (const k in obj) {
184
+ if (Object.hasOwn(obj, k)) {
185
+ result[k] = this.normalize(obj[k], refsOut, seen);
186
+ }
173
187
  }
174
188
  return result;
175
189
  }
@@ -195,10 +209,12 @@ var NormalizedCache = class {
195
209
  return result2;
196
210
  }
197
211
  const result = {};
198
- for (const [k, v] of Object.entries(obj)) {
199
- const denormalized = this.denormalize(v, seen);
200
- if (denormalized === void 0) return void 0;
201
- result[k] = denormalized;
212
+ for (const k in obj) {
213
+ if (Object.hasOwn(obj, k)) {
214
+ const denormalized = this.denormalize(obj[k], seen);
215
+ if (denormalized === void 0) return void 0;
216
+ result[k] = denormalized;
217
+ }
202
218
  }
203
219
  return result;
204
220
  }
@@ -217,14 +233,14 @@ var NormalizedCache = class {
217
233
  if (this.swrMs > 0 && now <= entry.expiresAt + this.swrMs) {
218
234
  isStale = true;
219
235
  } else {
220
- this.queryStore.delete(key);
236
+ this.deleteQueryEntry(key, entry);
221
237
  this._misses++;
222
238
  return void 0;
223
239
  }
224
240
  }
225
241
  const denormalized = this.denormalize(entry.data);
226
242
  if (denormalized === void 0) {
227
- this.queryStore.delete(key);
243
+ this.deleteQueryEntry(key, entry);
228
244
  this._misses++;
229
245
  return void 0;
230
246
  }
@@ -243,27 +259,46 @@ var NormalizedCache = class {
243
259
  }
244
260
  set(key, data) {
245
261
  if (!this.enabled) return;
246
- const normalizedData = this.normalize(data);
247
- this.queryStore.delete(key);
262
+ const refs = /* @__PURE__ */ new Set();
263
+ const normalizedData = this.normalize(data, refs);
264
+ const existing = this.queryStore.get(key);
265
+ if (existing) {
266
+ this.deleteQueryEntry(key, existing);
267
+ }
248
268
  if (this.maxSize > 0 && this.queryStore.size >= this.maxSize) {
249
269
  const firstKey = this.queryStore.keys().next().value;
250
270
  if (firstKey !== void 0) {
251
- this.queryStore.delete(firstKey);
271
+ const firstEntry = this.queryStore.get(firstKey);
272
+ if (firstEntry) this.deleteQueryEntry(firstKey, firstEntry);
273
+ }
274
+ }
275
+ for (const ref of refs) {
276
+ this.refCount.set(ref, (this.refCount.get(ref) ?? 0) + 1);
277
+ }
278
+ this.queryStore.set(key, { data: normalizedData, refs, expiresAt: Date.now() + this.ttl });
279
+ }
280
+ deleteQueryEntry(key, entry) {
281
+ this.queryStore.delete(key);
282
+ for (const ref of entry.refs) {
283
+ const count = (this.refCount.get(ref) ?? 0) - 1;
284
+ if (count <= 0) {
285
+ this.refCount.delete(ref);
286
+ this.entityStore.delete(ref);
287
+ } else {
288
+ this.refCount.set(ref, count);
252
289
  }
253
- this.scheduleGc();
254
290
  }
255
- this.queryStore.set(key, { data: normalizedData, expiresAt: Date.now() + this.ttl });
256
291
  }
257
292
  delete(key) {
258
- return this.queryStore.delete(key);
293
+ const entry = this.queryStore.get(key);
294
+ if (!entry) return false;
295
+ this.deleteQueryEntry(key, entry);
296
+ return true;
259
297
  }
260
298
  clear() {
261
- if (this.gcTimeout) {
262
- clearTimeout(this.gcTimeout);
263
- this.gcTimeout = void 0;
264
- }
265
299
  this.queryStore.clear();
266
300
  this.entityStore.clear();
301
+ this.refCount.clear();
267
302
  this._hits = 0;
268
303
  this._misses = 0;
269
304
  this._stales = 0;
@@ -280,7 +315,9 @@ var NormalizedCache = class {
280
315
  for (const key of this.queryStore.keys()) {
281
316
  if (test(key)) toDelete.push(key);
282
317
  }
283
- for (const key of toDelete) this.queryStore.delete(key);
318
+ for (const key of toDelete) {
319
+ this.delete(key);
320
+ }
284
321
  return toDelete.length;
285
322
  }
286
323
  get stats() {
@@ -298,53 +335,6 @@ var NormalizedCache = class {
298
335
  this._misses = 0;
299
336
  this._stales = 0;
300
337
  }
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
338
  };
349
339
 
350
340
  // src/cache/index.ts
@@ -2541,7 +2531,7 @@ function mapFavorites(fav) {
2541
2531
 
2542
2532
  // src/client/index.ts
2543
2533
  var DEFAULT_API_URL = "https://graphql.anilist.co";
2544
- var LIB_VERSION = "2.2.1" ;
2534
+ var LIB_VERSION = "2.3.0" ;
2545
2535
  var AniListClient = class {
2546
2536
  apiUrl;
2547
2537
  headers;