@shiinasaku/github-card 4.1.0 → 5.0.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/src/gallery.ts ADDED
@@ -0,0 +1,37 @@
1
+ import { renderCard, type CardOpts } from "./card";
2
+ import type { LanguageStat, UserProfile, UserStats } from "./types";
3
+ import { escapeXml, themes } from "./utils";
4
+
5
+ /** Inline SVG cards so the preview also works through GitHub's image proxy. */
6
+ export function renderThemeGallery(
7
+ user: UserProfile,
8
+ stats: UserStats,
9
+ languages: LanguageStat[],
10
+ options: CardOpts = {},
11
+ ): string {
12
+ const cards = Object.keys(themes).map((theme) => {
13
+ const svg = renderCard(user, stats, languages, { ...options, theme, idPrefix: theme });
14
+ // Dimensions come from our renderer, not user-supplied SVG.
15
+ const [, width, height] = svg.match(/^<svg\b[^>]*\bviewBox="0 0 (\d+) (\d+)"/) ?? [];
16
+ if (!width || !height) throw new Error("Card renderer returned an invalid viewBox");
17
+ return { theme, svg, width: Number(width), height: Number(height) };
18
+ });
19
+ const columns = 2;
20
+ const gap = 20;
21
+ const labelHeight = 22;
22
+ const cardWidth = Math.max(...cards.map((card) => card.width));
23
+ const cellHeight = Math.max(...cards.map((card) => card.height)) + labelHeight;
24
+ const rows = Math.ceil(cards.length / columns);
25
+ const width = columns * cardWidth + (columns + 1) * gap;
26
+ const height = rows * cellHeight + (rows + 1) * gap;
27
+
28
+ const blocks = cards.map(({ theme, svg }, index) => {
29
+ const x = gap + (index % columns) * (cardWidth + gap);
30
+ const y = gap + Math.floor(index / columns) * (cellHeight + gap) + labelHeight;
31
+ const card = svg.replace(/^<svg\b/, `<svg x="${x}" y="${y}"`);
32
+ const label = `<text x="${x + 4}" y="${y - 8}" fill="#e2e8f0" font-size="13" font-family="system-ui,sans-serif" font-weight="700">${escapeXml(theme)}</text>`;
33
+ return label + card;
34
+ });
35
+
36
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}" role="img" aria-label="GitHub Card themes for user"><rect width="100%" height="100%" fill="#0b1220"/>${blocks.join("")}</svg>`;
37
+ }
package/src/github.ts CHANGED
@@ -11,18 +11,18 @@ import {
11
11
  export { NotFoundError, RateLimitError, AuthError, UpstreamError };
12
12
 
13
13
  const githubClient = new GitHubClient({
14
- token: Bun.env.GITHUB_TOKEN || process.env?.GITHUB_TOKEN || "",
14
+ token: Bun.env.GITHUB_TOKEN ?? "",
15
15
  userAgent: "github-card",
16
16
  });
17
17
 
18
18
  const CACHE_FRESH_MS = 30 * 60 * 1000;
19
19
  const CACHE_STALE_MS = 30 * 60 * 1000;
20
- const CACHE_VERSION = 7;
20
+ const CACHE_VERSION = 9;
21
21
  const REDIS_TTL_SECONDS = Math.ceil((CACHE_FRESH_MS + CACHE_STALE_MS) / 1000);
22
22
  const FETCH_TIMEOUT_MS = 6000;
23
23
  const AVATAR_TIMEOUT_MS = 4000;
24
24
  const MAX_AVATAR_BYTES = 256 * 1024;
25
- const MAX_PAGES = 5;
25
+ const MAX_PAGES = 100;
26
26
  const REPO_PAGE_SIZE = 100;
27
27
  const MAX_L1_ENTRIES = 1000;
28
28
  const REDIS_PROFILE_PREFIX = "profile:";
@@ -58,7 +58,7 @@ const refreshInFlight = new Set<string>();
58
58
 
59
59
  function evictL1IfNeeded() {
60
60
  if (memCache.size <= MAX_L1_ENTRIES) return;
61
- const oldestKey = memCache.keys().next().value as string | undefined;
61
+ const oldestKey = memCache.keys().next().value;
62
62
  if (oldestKey) memCache.delete(oldestKey);
63
63
  }
64
64
 
@@ -123,8 +123,8 @@ export function getCacheMetrics() {
123
123
  fresh,
124
124
  stale,
125
125
  expired,
126
- ttlFreshSeconds: 1800,
127
- ttlStaleSeconds: 1800,
126
+ ttlFreshSeconds: CACHE_FRESH_MS / 1000,
127
+ ttlStaleSeconds: CACHE_STALE_MS / 1000,
128
128
  };
129
129
  }
130
130
 
@@ -133,18 +133,61 @@ function normalizeUsername(username: string): string {
133
133
  }
134
134
 
135
135
  function normalizeOrgFilters(orgs?: string[]): string[] {
136
- return Array.from(new Set((orgs || []).map((o) => o.trim().toLowerCase()).filter(Boolean)));
136
+ return Array.from(
137
+ new Set((orgs || []).map((o) => o.trim().toLowerCase()).filter(Boolean)),
138
+ ).sort();
137
139
  }
138
140
 
139
141
  type FetchOptions = {
140
142
  includeLanguages?: boolean;
141
143
  langCount?: number;
144
+ hideLangs?: string[];
145
+ showLangs?: string[];
142
146
  scope?: "personal" | "org" | "all";
143
147
  orgs?: string[];
144
148
  affiliations?: "owner" | "affiliated";
145
149
  forceRefresh?: boolean;
146
150
  };
147
151
 
152
+ type RepoNode = {
153
+ nameWithOwner: string;
154
+ isFork?: boolean;
155
+ stargazers: { totalCount: number };
156
+ owner?: { login: string; __typename: string } | null;
157
+ languages?: { edges: LangEdge[] | null } | null;
158
+ };
159
+
160
+ type PageInfo = { hasNextPage: boolean; endCursor: string | null };
161
+ type RepoConnection = { pageInfo: PageInfo; nodes: RepoNode[] | null };
162
+
163
+ type ProfileUser = {
164
+ login: string;
165
+ name: string | null;
166
+ avatarUrl: string;
167
+ bio: string | null;
168
+ pronouns: string | null;
169
+ twitterUsername: string | null;
170
+ openPRs: { totalCount: number };
171
+ closedPRs: { totalCount: number };
172
+ mergedPRs: { totalCount: number };
173
+ openIssues: { totalCount: number };
174
+ closedIssues: { totalCount: number };
175
+ contributionsCollection: {
176
+ totalCommitContributions: number;
177
+ commitContributionsByRepository?: ContributionEntry[] | null;
178
+ pullRequestContributionsByRepository?: ContributionEntry[] | null;
179
+ issueContributionsByRepository?: ContributionEntry[] | null;
180
+ };
181
+ };
182
+
183
+ type OrgProfile = {
184
+ login: string;
185
+ name: string | null;
186
+ avatarUrl: string;
187
+ description: string | null;
188
+ twitterUsername: string | null;
189
+ };
190
+
148
191
  const QUERY_PROFILE = `
149
192
  query fullProfile($login: String!, $from: DateTime!, $to: DateTime!, $isOrg: Boolean!) {
150
193
  user(login: $login) {
@@ -186,9 +229,9 @@ query paginateRepos($login: String!, $cursor: String, $pageSize: Int!, $affiliat
186
229
  const QUERY_PAGINATE_ORG_CONTRIBS = `
187
230
  query paginateOrgContribs($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
188
231
  user(login: $login) {
189
- repositoriesContributedTo(first: $pageSize, contributionTypes: [COMMIT, PULL_REQUEST, REPOSITORY], includeUserRepositories: false, after: $cursor) {
232
+ repositoriesContributedTo(first: $pageSize, contributionTypes: [COMMIT, ISSUE, PULL_REQUEST, REPOSITORY], includeUserRepositories: false, after: $cursor) {
190
233
  pageInfo { hasNextPage endCursor }
191
- nodes { nameWithOwner owner { login __typename } stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
234
+ nodes { nameWithOwner isFork owner { login __typename } stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
192
235
  }
193
236
  }
194
237
  }
@@ -205,21 +248,23 @@ query paginateOrg($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs
205
248
  }
206
249
  `;
207
250
 
208
- async function postGraphQL(query: string, variables: Record<string, unknown>) {
209
- return githubClient.request(query, {
251
+ async function postGraphQL<T>(query: string, variables: Record<string, unknown>): Promise<T> {
252
+ return githubClient.request<T>(query, {
210
253
  variables,
211
254
  timeoutMs: FETCH_TIMEOUT_MS,
212
255
  });
213
256
  }
214
257
 
215
258
  type LangMap = Map<string, { size: number; color: string }>;
216
- function accLangs(edges: any[], map: LangMap) {
217
- if (!edges) return;
218
- for (const e of edges) {
219
- if (!e?.node?.name || !e.size) continue;
220
- const cur = map.get(e.node.name);
221
- if (cur) cur.size += e.size;
222
- else map.set(e.node.name, { size: e.size, color: e.node.color || "#ccc" });
259
+ type LangEdge = { size: number; node: { name: string; color: string | null } };
260
+
261
+ function accLangs(edges: LangEdge[] | null | undefined, map: LangMap): void {
262
+ if (!edges?.length) return;
263
+ for (const edge of edges) {
264
+ if (!edge?.node?.name || !edge.size) continue;
265
+ const existing = map.get(edge.node.name);
266
+ if (existing) existing.size += edge.size;
267
+ else map.set(edge.node.name, { size: edge.size, color: edge.node.color ?? "#ccc" });
223
268
  }
224
269
  }
225
270
 
@@ -244,23 +289,33 @@ async function fetchAvatarAsBase64(url: string | null | undefined): Promise<stri
244
289
  }
245
290
  }
246
291
 
247
- function sumContributions(entries: any[], orgsSet: Set<string> | null) {
248
- let tot = 0;
249
- for (const e of entries || []) {
250
- const owner = e?.repository?.owner;
292
+ type ContributionEntry = {
293
+ repository?: { owner?: { login?: string; __typename?: string } | null } | null;
294
+ contributions?: { totalCount?: number } | null;
295
+ };
296
+
297
+ function sumContributions(
298
+ entries: ContributionEntry[] | null | undefined,
299
+ orgsSet: Set<string> | null,
300
+ ): number {
301
+ if (!entries?.length) return 0;
302
+ let total = 0;
303
+ for (const entry of entries) {
304
+ const owner = entry?.repository?.owner;
251
305
  if (owner?.__typename !== "Organization" || !owner?.login) continue;
252
306
  if (orgsSet && !orgsSet.has(owner.login.toLowerCase())) continue;
253
- tot += e?.contributions?.totalCount || 0;
307
+ total += entry?.contributions?.totalCount ?? 0;
254
308
  }
255
- return tot;
309
+ return total;
256
310
  }
257
311
 
258
- async function directFetch(username: string, opts: FetchOptions): Promise<ProfileData> {
312
+ type EffectiveFetchOptions = Required<FetchOptions>;
313
+
314
+ async function directFetch(username: string, opts: EffectiveFetchOptions): Promise<ProfileData> {
259
315
  const isPersonal = opts.scope !== "org";
260
316
  const isOrg = opts.scope === "org" || opts.scope === "all";
261
- const fetchLangs = opts.includeLanguages ?? true;
262
- const orgsArr = normalizeOrgFilters(opts.orgs);
263
- const orgsSet = orgsArr.length > 0 ? new Set(orgsArr) : null;
317
+ const fetchLangs = opts.includeLanguages;
318
+ const orgsSet = opts.orgs.length > 0 ? new Set(opts.orgs) : null;
264
319
  const affiliations =
265
320
  opts.affiliations === "owner" ? ["OWNER"] : ["OWNER", "ORGANIZATION_MEMBER", "COLLABORATOR"];
266
321
 
@@ -268,20 +323,20 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
268
323
  const from = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)).toISOString();
269
324
  const to = now.toISOString();
270
325
 
271
- let user: any = null;
326
+ let user: ProfileUser | OrgProfile | null = null;
272
327
  let isOrgAccount = false;
273
328
  try {
274
- const data = await postGraphQL(QUERY_PROFILE, {
329
+ const data = await postGraphQL<{ user: ProfileUser | null }>(QUERY_PROFILE, {
275
330
  login: username,
276
331
  from,
277
332
  to,
278
- isOrg,
333
+ isOrg: opts.scope === "org",
279
334
  });
280
335
  user = data.user;
281
336
  if (!user) throw new NotFoundError();
282
337
  } catch (err) {
283
338
  if (err instanceof NotFoundError) {
284
- const data = await postGraphQL(QUERY_ORG, {
339
+ const data = await postGraphQL<{ organization: OrgProfile | null }>(QUERY_ORG, {
285
340
  login: username,
286
341
  });
287
342
  user = data.organization;
@@ -295,35 +350,37 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
295
350
  let stars = 0;
296
351
  let reposCount = 0;
297
352
 
298
- const processNode = (n: any, scopeCheck: boolean) => {
299
- if (!n || !n.nameWithOwner || seenRepos.has(n.nameWithOwner)) return;
300
- if (
301
- scopeCheck &&
302
- n.owner?.__typename === "Organization" &&
303
- orgsSet &&
304
- !orgsSet.has(n.owner.login.toLowerCase())
305
- )
306
- return;
307
- seenRepos.add(n.nameWithOwner);
308
- stars += n.stargazers?.totalCount || 0;
353
+ const processNode = (node: RepoNode, scopeCheck: boolean): void => {
354
+ if (!node?.nameWithOwner || node.isFork || seenRepos.has(node.nameWithOwner)) return;
355
+ if (scopeCheck) {
356
+ const owner = node.owner;
357
+ if (owner?.__typename !== "Organization" || !owner.login) return;
358
+ if (orgsSet && !orgsSet.has(owner.login.toLowerCase())) return;
359
+ }
360
+ seenRepos.add(node.nameWithOwner);
361
+ stars += node.stargazers?.totalCount ?? 0;
309
362
  reposCount++;
310
- if (fetchLangs) accLangs(n.languages?.edges, langMap);
363
+ if (fetchLangs) accLangs(node.languages?.edges ?? null, langMap);
311
364
  };
312
365
 
313
- const getConnection = (data: any, query: string) =>
366
+ type PaginateData = {
367
+ user?: { repositories?: RepoConnection; repositoriesContributedTo?: RepoConnection } | null;
368
+ organization?: { repositories: RepoConnection } | null;
369
+ };
370
+
371
+ const getConnection = (data: PaginateData, query: string): RepoConnection | undefined =>
314
372
  data.user
315
373
  ? query.includes("repositoriesContributedTo")
316
- ? data.user.repositoriesContributedTo
317
- : data.user.repositories
374
+ ? (data.user.repositoriesContributedTo ?? undefined)
375
+ : (data.user.repositories ?? undefined)
318
376
  : data.organization?.repositories;
319
377
 
320
- const traversePages = async (query: string, scopeCheck: boolean) => {
378
+ const traversePages = async (query: string, scopeCheck: boolean): Promise<void> => {
321
379
  let cursor: string | null = null;
322
- let page = 0;
323
- let hasNext = true;
380
+ const seenCursors = new Set<string>();
324
381
 
325
- while (hasNext && page++ < MAX_PAGES) {
326
- const data = await postGraphQL(query, {
382
+ for (let page = 0; page < MAX_PAGES; page++) {
383
+ const data = await postGraphQL<PaginateData>(query, {
327
384
  login: username,
328
385
  cursor,
329
386
  pageSize: REPO_PAGE_SIZE,
@@ -331,11 +388,23 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
331
388
  affiliations,
332
389
  });
333
390
  const nextConn = getConnection(data, query);
334
- for (const n of nextConn?.nodes || []) processNode(n, scopeCheck);
335
- hasNext = nextConn?.pageInfo?.hasNextPage;
336
- cursor = nextConn?.pageInfo?.endCursor;
337
- if (hasNext && !cursor) break;
391
+ if (
392
+ !nextConn ||
393
+ !Array.isArray(nextConn.nodes) ||
394
+ typeof nextConn.pageInfo?.hasNextPage !== "boolean"
395
+ ) {
396
+ throw new UpstreamError("GitHub repository pagination connection is missing or invalid");
397
+ }
398
+ for (const node of nextConn.nodes) processNode(node, scopeCheck);
399
+ if (!nextConn.pageInfo.hasNextPage) return;
400
+ const nextCursor = nextConn.pageInfo.endCursor;
401
+ if (typeof nextCursor !== "string" || !nextCursor.trim() || seenCursors.has(nextCursor)) {
402
+ throw new UpstreamError("GitHub repository pagination cursor is missing or invalid");
403
+ }
404
+ seenCursors.add(nextCursor);
405
+ cursor = nextCursor;
338
406
  }
407
+ throw new UpstreamError(`GitHub repository pagination exceeded ${MAX_PAGES} pages`);
339
408
  };
340
409
 
341
410
  const tasks: Promise<void>[] = [];
@@ -348,39 +417,51 @@ async function directFetch(username: string, opts: FetchOptions): Promise<Profil
348
417
 
349
418
  const [avatarDataUrl] = await Promise.all([fetchAvatarAsBase64(user.avatarUrl), ...tasks]);
350
419
 
351
- let commits = 0;
352
- let prs = 0;
353
- let issues = 0;
420
+ let commits: number | null = null;
421
+ let prs: number | null = null;
422
+ let issues: number | null = null;
354
423
 
355
424
  if (!isOrgAccount) {
356
- const c = user.contributionsCollection;
425
+ const profile = user as ProfileUser;
426
+ const c = profile.contributionsCollection;
357
427
  if (opts.scope === "org") {
358
428
  commits = sumContributions(c?.commitContributionsByRepository, orgsSet);
359
429
  prs = sumContributions(c?.pullRequestContributionsByRepository, orgsSet);
360
430
  issues = sumContributions(c?.issueContributionsByRepository, orgsSet);
361
431
  } else {
362
- commits = c?.totalCommitContributions || 0;
432
+ commits = c?.totalCommitContributions ?? 0;
363
433
  prs =
364
- (user.openPRs?.totalCount || 0) +
365
- (user.closedPRs?.totalCount || 0) +
366
- (user.mergedPRs?.totalCount || 0);
367
- issues = (user.openIssues?.totalCount || 0) + (user.closedIssues?.totalCount || 0);
434
+ (profile.openPRs?.totalCount ?? 0) +
435
+ (profile.closedPRs?.totalCount ?? 0) +
436
+ (profile.mergedPRs?.totalCount ?? 0);
437
+ issues = (profile.openIssues?.totalCount ?? 0) + (profile.closedIssues?.totalCount ?? 0);
368
438
  }
369
439
  }
370
440
 
441
+ const hiddenLanguages = new Set(opts.hideLangs);
442
+ const shownLanguages = new Set(opts.showLangs);
371
443
  const languages = Array.from(langMap.entries())
444
+ .filter(([name]) => {
445
+ const normalized = name.trim().toLowerCase();
446
+ return (
447
+ !hiddenLanguages.has(normalized) && (!shownLanguages.size || shownLanguages.has(normalized))
448
+ );
449
+ })
372
450
  .map(([name, { size, color }]) => ({ name, size, color }))
373
451
  .sort((a, b) => b.size - a.size)
374
- .slice(0, opts.langCount || 5);
452
+ .slice(0, opts.langCount);
453
+
454
+ const profileUser = !isOrgAccount ? (user as ProfileUser) : null;
455
+ const orgUser = isOrgAccount ? (user as OrgProfile) : null;
375
456
 
376
457
  return {
377
458
  user: {
378
459
  login: user.login,
379
460
  name: user.name,
380
461
  avatarUrl: avatarDataUrl,
381
- bio: user.bio || user.description,
382
- pronouns: user.pronouns,
383
- twitter: user.twitterUsername,
462
+ bio: isOrgAccount ? (orgUser?.description ?? null) : (profileUser?.bio ?? null),
463
+ pronouns: isOrgAccount ? null : (profileUser?.pronouns ?? null),
464
+ twitter: user.twitterUsername ?? null,
384
465
  },
385
466
  stats: { stars, repos: reposCount, prs, issues, commits },
386
467
  languages,
@@ -391,18 +472,39 @@ export async function getProfileData(
391
472
  username: string,
392
473
  opts: FetchOptions = {},
393
474
  ): Promise<ProfileData> {
394
- const norm = normalizeUsername(username);
395
- const orgs = normalizeOrgFilters(opts.orgs);
475
+ username = normalizeUsername(username);
476
+ const effectiveOpts: EffectiveFetchOptions = {
477
+ scope: opts.scope ?? "personal",
478
+ affiliations: opts.affiliations ?? "owner",
479
+ includeLanguages: opts.includeLanguages ?? true,
480
+ langCount: Number.isFinite(opts.langCount)
481
+ ? Math.max(1, Math.min(10, Math.floor(opts.langCount!)))
482
+ : 5,
483
+ hideLangs: normalizeOrgFilters(opts.hideLangs),
484
+ showLangs: normalizeOrgFilters(opts.showLangs),
485
+ orgs: normalizeOrgFilters(opts.orgs),
486
+ forceRefresh: opts.forceRefresh ?? false,
487
+ };
396
488
  const cacheKey = Bun.hash(
397
- `v${CACHE_VERSION}:${norm}:${opts.scope || "personal"}:${opts.affiliations || "affiliated"}:${opts.includeLanguages !== false}:${opts.langCount || 5}:${orgs.join("|")}`,
489
+ JSON.stringify([
490
+ CACHE_VERSION,
491
+ username,
492
+ effectiveOpts.scope,
493
+ effectiveOpts.affiliations,
494
+ effectiveOpts.includeLanguages,
495
+ effectiveOpts.langCount,
496
+ effectiveOpts.orgs,
497
+ effectiveOpts.hideLangs,
498
+ effectiveOpts.showLangs,
499
+ ]),
398
500
  ).toString(36);
399
501
 
400
- if (!opts.forceRefresh) {
502
+ if (!effectiveOpts.forceRefresh) {
401
503
  const mem = memCache.get(cacheKey);
402
504
  const now = Date.now();
403
505
  if (mem && mem.expiresAt > now) {
404
506
  if (mem.staleAt <= now && !refreshInFlight.has(cacheKey))
405
- triggerBackgroundRefresh(username, opts, cacheKey);
507
+ triggerBackgroundRefresh(username, effectiveOpts, cacheKey);
406
508
  return mem.data;
407
509
  }
408
510
 
@@ -410,14 +512,14 @@ export async function getProfileData(
410
512
  if (cached) {
411
513
  setMemoryCache(cacheKey, cached);
412
514
  if (cached.staleAt <= now && !refreshInFlight.has(cacheKey))
413
- triggerBackgroundRefresh(username, opts, cacheKey);
515
+ triggerBackgroundRefresh(username, effectiveOpts, cacheKey);
414
516
  return cached.data;
415
517
  }
416
518
  }
417
519
 
418
520
  if (inFlight.has(cacheKey)) return inFlight.get(cacheKey)!;
419
521
 
420
- const promise = directFetch(username, opts)
522
+ const promise = directFetch(username, effectiveOpts)
421
523
  .then((data) => saveProfileCache(cacheKey, data))
422
524
  .finally(() => {
423
525
  inFlight.delete(cacheKey);
@@ -427,11 +529,11 @@ export async function getProfileData(
427
529
  return promise;
428
530
  }
429
531
 
430
- function triggerBackgroundRefresh(username: string, opts: FetchOptions, cacheKey: string) {
532
+ function triggerBackgroundRefresh(username: string, opts: EffectiveFetchOptions, cacheKey: string) {
431
533
  if (refreshInFlight.has(cacheKey)) return;
432
534
  refreshInFlight.add(cacheKey);
433
535
 
434
- void directFetch(username, { ...opts, forceRefresh: true })
536
+ void directFetch(username, opts)
435
537
  .then((data) => saveProfileCache(cacheKey, data))
436
538
  .catch(() => {})
437
539
  .finally(() => {