@shiinasaku/github-card 2.0.0 → 4.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +159 -261
- package/dist/card.d.ts +16 -0
- package/dist/lib.d.ts +13 -0
- package/dist/lib.js +1347 -0
- package/dist/types.d.ts +37 -0
- package/dist/utils/colors.d.ts +2 -0
- package/dist/utils/font.d.ts +4 -0
- package/dist/utils/format.d.ts +10 -0
- package/dist/utils/icons.d.ts +11 -0
- package/dist/utils/index.d.ts +6 -0
- package/dist/utils/languages.d.ts +2 -0
- package/dist/utils/themes.d.ts +16 -0
- package/package.json +30 -20
- package/src/card.ts +537 -113
- package/src/env.d.ts +4 -0
- package/src/github.ts +406 -480
- package/src/index.ts +148 -134
- package/src/input.css +1 -0
- package/src/lib.ts +1 -1
- package/src/tailwind.css +3837 -0
- package/src/types.ts +1 -0
- package/src/utils/colors.ts +85 -0
- package/src/utils/font.ts +5 -19
- package/src/utils/format.ts +54 -16
- package/src/utils/github-client.ts +198 -0
- package/src/utils/icons.ts +27 -9
- package/src/utils/index.ts +2 -1
- package/src/utils/themes.ts +154 -19
- package/fonts/Roboto-subset.woff2 +0 -0
package/src/github.ts
CHANGED
|
@@ -1,572 +1,498 @@
|
|
|
1
|
-
import type { ProfileData
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
1
|
+
import type { ProfileData } from "./types";
|
|
2
|
+
import { RedisClient } from "bun";
|
|
3
|
+
import {
|
|
4
|
+
GitHubClient,
|
|
5
|
+
NotFoundError,
|
|
6
|
+
RateLimitError,
|
|
7
|
+
AuthError,
|
|
8
|
+
GitHubError as UpstreamError,
|
|
9
|
+
} from "./utils/github-client";
|
|
10
|
+
|
|
11
|
+
export { NotFoundError, RateLimitError, AuthError, UpstreamError };
|
|
12
|
+
|
|
13
|
+
const githubClient = new GitHubClient({
|
|
14
|
+
token: Bun.env.GITHUB_TOKEN ?? "",
|
|
15
|
+
userAgent: "github-card",
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
const CACHE_FRESH_MS = 30 * 60 * 1000;
|
|
19
|
+
const CACHE_STALE_MS = 30 * 60 * 1000;
|
|
20
|
+
const CACHE_VERSION = 8;
|
|
21
|
+
const REDIS_TTL_SECONDS = Math.ceil((CACHE_FRESH_MS + CACHE_STALE_MS) / 1000);
|
|
22
|
+
const FETCH_TIMEOUT_MS = 6000;
|
|
23
|
+
const AVATAR_TIMEOUT_MS = 4000;
|
|
24
|
+
const MAX_AVATAR_BYTES = 256 * 1024;
|
|
25
|
+
const MAX_PAGES = 5;
|
|
26
|
+
const REPO_PAGE_SIZE = 100;
|
|
27
|
+
const MAX_L1_ENTRIES = 1000;
|
|
28
|
+
const REDIS_PROFILE_PREFIX = "profile:";
|
|
29
|
+
|
|
30
|
+
let redisClient: RedisClient | null = null;
|
|
31
|
+
try {
|
|
32
|
+
if (Bun.env.REDIS_URL) {
|
|
33
|
+
redisClient = new RedisClient(Bun.env.REDIS_URL);
|
|
21
34
|
}
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error("Bun RedisClient failed to initialize", e);
|
|
22
37
|
}
|
|
23
38
|
|
|
24
|
-
export
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
39
|
+
export async function isRedisReachable(): Promise<boolean> {
|
|
40
|
+
if (!redisClient) return false;
|
|
41
|
+
try {
|
|
42
|
+
return (await redisClient.ping()) === "PONG";
|
|
43
|
+
} catch {
|
|
44
|
+
return false;
|
|
28
45
|
}
|
|
29
46
|
}
|
|
30
47
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
lower.includes("couldn't find user")
|
|
38
|
-
) {
|
|
39
|
-
return new NotFoundError("User not found");
|
|
40
|
-
}
|
|
48
|
+
type CachedData = {
|
|
49
|
+
v: number;
|
|
50
|
+
staleAt: number;
|
|
51
|
+
expiresAt: number;
|
|
52
|
+
data: ProfileData;
|
|
53
|
+
};
|
|
41
54
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
55
|
+
const memCache = new Map<string, CachedData>();
|
|
56
|
+
const inFlight = new Map<string, Promise<ProfileData>>();
|
|
57
|
+
const refreshInFlight = new Set<string>();
|
|
45
58
|
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
return new AuthError("GitHub authentication failed");
|
|
52
|
-
}
|
|
59
|
+
function evictL1IfNeeded() {
|
|
60
|
+
if (memCache.size <= MAX_L1_ENTRIES) return;
|
|
61
|
+
const oldestKey = memCache.keys().next().value as string | undefined;
|
|
62
|
+
if (oldestKey) memCache.delete(oldestKey);
|
|
63
|
+
}
|
|
53
64
|
|
|
54
|
-
|
|
65
|
+
function setMemoryCache(cacheKey: string, payload: CachedData) {
|
|
66
|
+
memCache.set(cacheKey, payload);
|
|
67
|
+
evictL1IfNeeded();
|
|
55
68
|
}
|
|
56
69
|
|
|
57
|
-
function
|
|
58
|
-
|
|
70
|
+
async function writeRedisCache(cacheKey: string, payload: CachedData) {
|
|
71
|
+
if (!redisClient) return;
|
|
72
|
+
try {
|
|
73
|
+
await redisClient.set(
|
|
74
|
+
`${REDIS_PROFILE_PREFIX}${cacheKey}`,
|
|
75
|
+
JSON.stringify(payload),
|
|
76
|
+
"EX",
|
|
77
|
+
REDIS_TTL_SECONDS,
|
|
78
|
+
);
|
|
79
|
+
} catch {}
|
|
80
|
+
}
|
|
59
81
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
82
|
+
async function readRedisCache(cacheKey: string, now: number): Promise<CachedData | null> {
|
|
83
|
+
if (!redisClient) return null;
|
|
84
|
+
try {
|
|
85
|
+
const raw = await redisClient.get(`${REDIS_PROFILE_PREFIX}${cacheKey}`);
|
|
86
|
+
if (!raw) return null;
|
|
63
87
|
|
|
64
|
-
|
|
65
|
-
|
|
88
|
+
const parsed = JSON.parse(raw) as CachedData;
|
|
89
|
+
if (parsed.v !== CACHE_VERSION || parsed.expiresAt <= now || !parsed.data) return null;
|
|
90
|
+
return parsed;
|
|
91
|
+
} catch {
|
|
92
|
+
return null;
|
|
66
93
|
}
|
|
94
|
+
}
|
|
67
95
|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
96
|
+
async function saveProfileCache(cacheKey: string, data: ProfileData): Promise<ProfileData> {
|
|
97
|
+
const now = Date.now();
|
|
98
|
+
const payload: CachedData = {
|
|
99
|
+
v: CACHE_VERSION,
|
|
100
|
+
staleAt: now + CACHE_FRESH_MS,
|
|
101
|
+
expiresAt: now + CACHE_FRESH_MS + CACHE_STALE_MS,
|
|
102
|
+
data,
|
|
103
|
+
};
|
|
71
104
|
|
|
72
|
-
|
|
105
|
+
setMemoryCache(cacheKey, payload);
|
|
106
|
+
await writeRedisCache(cacheKey, payload);
|
|
107
|
+
return data;
|
|
73
108
|
}
|
|
74
109
|
|
|
75
|
-
function
|
|
76
|
-
|
|
77
|
-
|
|
110
|
+
export function getCacheMetrics() {
|
|
111
|
+
let fresh = 0;
|
|
112
|
+
let stale = 0;
|
|
113
|
+
let expired = 0;
|
|
114
|
+
const now = Date.now();
|
|
115
|
+
for (const entry of memCache.values()) {
|
|
116
|
+
if (entry.expiresAt <= now) expired++;
|
|
117
|
+
else if (entry.staleAt <= now) stale++;
|
|
118
|
+
else fresh++;
|
|
119
|
+
}
|
|
78
120
|
return {
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
121
|
+
total: memCache.size,
|
|
122
|
+
maxEntries: MAX_L1_ENTRIES,
|
|
123
|
+
fresh,
|
|
124
|
+
stale,
|
|
125
|
+
expired,
|
|
126
|
+
ttlFreshSeconds: CACHE_FRESH_MS / 1000,
|
|
127
|
+
ttlStaleSeconds: CACHE_STALE_MS / 1000,
|
|
82
128
|
};
|
|
83
129
|
}
|
|
84
130
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
}`;
|
|
88
|
-
|
|
89
|
-
const USER_META_FIELDS = `
|
|
90
|
-
login name avatarUrl bio pronouns twitterUsername
|
|
91
|
-
openPRs: pullRequests(states: OPEN) { totalCount }
|
|
92
|
-
closedPRs: pullRequests(states: CLOSED) { totalCount }
|
|
93
|
-
mergedPRs: pullRequests(states: MERGED) { totalCount }
|
|
94
|
-
openIssues: issues(states: OPEN) { totalCount }
|
|
95
|
-
closedIssues: issues(states: CLOSED) { totalCount }
|
|
96
|
-
contributionsCollection(from: $from, to: $to) { totalCommitContributions }`;
|
|
97
|
-
|
|
98
|
-
function buildUserReposQuery(langs: boolean): string {
|
|
99
|
-
return `query userInfo($login: String!, $cursor: String, $from: DateTime!, $to: DateTime!) {
|
|
100
|
-
user(login: $login) {
|
|
101
|
-
${USER_META_FIELDS}
|
|
102
|
-
repositories(first: 100, ownerAffiliations: OWNER, isFork: false, orderBy: {direction: DESC, field: STARGAZERS}, after: $cursor) {
|
|
103
|
-
totalCount
|
|
104
|
-
pageInfo { hasNextPage endCursor }
|
|
105
|
-
nodes {
|
|
106
|
-
stargazers { totalCount }
|
|
107
|
-
${langs ? LANG_FIELDS : ""}
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
}`;
|
|
131
|
+
function normalizeUsername(username: string): string {
|
|
132
|
+
return username.trim().toLowerCase();
|
|
112
133
|
}
|
|
113
134
|
|
|
114
|
-
function
|
|
115
|
-
return
|
|
116
|
-
user(login: $login) {
|
|
117
|
-
repositoriesContributedTo(
|
|
118
|
-
first: 100
|
|
119
|
-
contributionTypes: [COMMIT, PULL_REQUEST, REPOSITORY]
|
|
120
|
-
includeUserRepositories: false
|
|
121
|
-
after: $cursor
|
|
122
|
-
) {
|
|
123
|
-
totalCount
|
|
124
|
-
pageInfo { hasNextPage endCursor }
|
|
125
|
-
nodes {
|
|
126
|
-
owner { login __typename }
|
|
127
|
-
stargazers { totalCount }
|
|
128
|
-
${langs ? LANG_FIELDS : ""}
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
}`;
|
|
135
|
+
function normalizeOrgFilters(orgs?: string[]): string[] {
|
|
136
|
+
return Array.from(new Set((orgs || []).map((o) => o.trim().toLowerCase()).filter(Boolean)));
|
|
133
137
|
}
|
|
134
138
|
|
|
135
|
-
const QUERY_USER_META = `
|
|
136
|
-
query userMeta($login: String!, $from: DateTime!, $to: DateTime!) {
|
|
137
|
-
user(login: $login) {
|
|
138
|
-
${USER_META_FIELDS}
|
|
139
|
-
}
|
|
140
|
-
}`;
|
|
141
|
-
|
|
142
|
-
const CACHE_FRESH_SECONDS = 30 * 60;
|
|
143
|
-
const CACHE_STALE_SECONDS = 30 * 60;
|
|
144
|
-
const FETCH_TIMEOUT_MS = 8000;
|
|
145
|
-
const MAX_PAGES = 10; // Safety: 10 pages × 100 items = 1000 max
|
|
146
|
-
type CacheEntry = {
|
|
147
|
-
staleAt: number;
|
|
148
|
-
expiresAt: number;
|
|
149
|
-
value?: ProfileData;
|
|
150
|
-
inFlight?: Promise<ProfileData>;
|
|
151
|
-
};
|
|
152
|
-
const cache = new Map<string, CacheEntry>();
|
|
153
|
-
|
|
154
139
|
type FetchOptions = {
|
|
155
140
|
includeLanguages?: boolean;
|
|
156
141
|
langCount?: number;
|
|
157
142
|
scope?: "personal" | "org" | "all";
|
|
158
143
|
orgs?: string[];
|
|
144
|
+
affiliations?: "owner" | "affiliated";
|
|
159
145
|
forceRefresh?: boolean;
|
|
160
146
|
};
|
|
161
147
|
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const redisToken = Bun.env.UPSTASH_REDIS_REST_TOKEN || Bun.env.KV_REST_API_TOKEN || "";
|
|
169
|
-
let redisPromise: Promise<import("@upstash/redis").Redis | null> | null = null;
|
|
170
|
-
|
|
171
|
-
function buildCacheKey(raw: string): string {
|
|
172
|
-
return Bun.hash(raw).toString(36);
|
|
173
|
-
}
|
|
148
|
+
type RepoNode = {
|
|
149
|
+
nameWithOwner: string;
|
|
150
|
+
stargazers: { totalCount: number };
|
|
151
|
+
owner?: { login: string; __typename: string } | null;
|
|
152
|
+
languages?: { edges: LangEdge[] | null } | null;
|
|
153
|
+
};
|
|
174
154
|
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
155
|
+
type PageInfo = { hasNextPage: boolean; endCursor: string | null };
|
|
156
|
+
type RepoConnection = { pageInfo: PageInfo; nodes: RepoNode[] | null };
|
|
157
|
+
|
|
158
|
+
type ProfileUser = {
|
|
159
|
+
login: string;
|
|
160
|
+
name: string | null;
|
|
161
|
+
avatarUrl: string;
|
|
162
|
+
bio: string | null;
|
|
163
|
+
pronouns: string | null;
|
|
164
|
+
twitterUsername: string | null;
|
|
165
|
+
openPRs: { totalCount: number };
|
|
166
|
+
closedPRs: { totalCount: number };
|
|
167
|
+
mergedPRs: { totalCount: number };
|
|
168
|
+
openIssues: { totalCount: number };
|
|
169
|
+
closedIssues: { totalCount: number };
|
|
170
|
+
contributionsCollection: {
|
|
171
|
+
totalCommitContributions: number;
|
|
172
|
+
commitContributionsByRepository?: ContributionEntry[] | null;
|
|
173
|
+
pullRequestContributionsByRepository?: ContributionEntry[] | null;
|
|
174
|
+
issueContributionsByRepository?: ContributionEntry[] | null;
|
|
175
|
+
};
|
|
176
|
+
};
|
|
184
177
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
178
|
+
type OrgProfile = {
|
|
179
|
+
login: string;
|
|
180
|
+
name: string | null;
|
|
181
|
+
avatarUrl: string;
|
|
182
|
+
description: string | null;
|
|
183
|
+
twitterUsername: string | null;
|
|
184
|
+
};
|
|
190
185
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
186
|
+
const QUERY_PROFILE = `
|
|
187
|
+
query fullProfile($login: String!, $from: DateTime!, $to: DateTime!, $isOrg: Boolean!) {
|
|
188
|
+
user(login: $login) {
|
|
189
|
+
login name avatarUrl bio pronouns twitterUsername
|
|
190
|
+
openPRs: pullRequests(states: OPEN) { totalCount }
|
|
191
|
+
closedPRs: pullRequests(states: CLOSED) { totalCount }
|
|
192
|
+
mergedPRs: pullRequests(states: MERGED) { totalCount }
|
|
193
|
+
openIssues: issues(states: OPEN) { totalCount }
|
|
194
|
+
closedIssues: issues(states: CLOSED) { totalCount }
|
|
195
|
+
contributionsCollection(from: $from, to: $to) {
|
|
196
|
+
totalCommitContributions
|
|
197
|
+
commitContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
|
|
198
|
+
pullRequestContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
|
|
199
|
+
issueContributionsByRepository(maxRepositories: 100) @include(if: $isOrg) { repository { owner { login __typename } } contributions(first:1) { totalCount } }
|
|
195
200
|
}
|
|
196
|
-
if (entry.staleAt <= now) stale++;
|
|
197
|
-
else fresh++;
|
|
198
201
|
}
|
|
199
|
-
|
|
200
|
-
return {
|
|
201
|
-
total: cache.size,
|
|
202
|
-
fresh,
|
|
203
|
-
stale,
|
|
204
|
-
expired,
|
|
205
|
-
ttlFreshSeconds: CACHE_FRESH_SECONDS,
|
|
206
|
-
ttlStaleSeconds: CACHE_STALE_SECONDS,
|
|
207
|
-
};
|
|
208
202
|
}
|
|
203
|
+
`;
|
|
209
204
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
await redis.ping();
|
|
215
|
-
return true;
|
|
216
|
-
} catch {
|
|
217
|
-
return false;
|
|
205
|
+
const QUERY_ORG = `
|
|
206
|
+
query fullOrg($login: String!) {
|
|
207
|
+
organization(login: $login) {
|
|
208
|
+
login name avatarUrl description twitterUsername
|
|
218
209
|
}
|
|
219
210
|
}
|
|
211
|
+
`;
|
|
220
212
|
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
213
|
+
const QUERY_PAGINATE_USER_REPOS = `
|
|
214
|
+
query paginateRepos($login: String!, $cursor: String, $pageSize: Int!, $affiliations: [RepositoryAffiliation!], $fetchLangs: Boolean!) {
|
|
215
|
+
user(login: $login) {
|
|
216
|
+
repositories(first: $pageSize, after: $cursor, ownerAffiliations: $affiliations, isFork: false, orderBy: {direction: DESC, field: STARGAZERS}) {
|
|
217
|
+
pageInfo { hasNextPage endCursor }
|
|
218
|
+
nodes { nameWithOwner stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
|
|
219
|
+
}
|
|
227
220
|
}
|
|
228
|
-
return { value: entry.value, stale: entry.staleAt <= Date.now() };
|
|
229
221
|
}
|
|
222
|
+
`;
|
|
230
223
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
});
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
function accumulateLanguageEdges(
|
|
241
|
-
edges: Array<{ size: number; node: { color?: string; name?: string } }> = [],
|
|
242
|
-
map: Map<string, { size: number; color: string }>,
|
|
243
|
-
) {
|
|
244
|
-
for (const edge of edges) {
|
|
245
|
-
if (!edge?.node?.name || !edge.size) continue;
|
|
246
|
-
const current = map.get(edge.node.name);
|
|
247
|
-
if (current) {
|
|
248
|
-
current.size += edge.size;
|
|
249
|
-
} else {
|
|
250
|
-
map.set(edge.node.name, { size: edge.size, color: edge.node.color || "#ccc" });
|
|
224
|
+
const QUERY_PAGINATE_ORG_CONTRIBS = `
|
|
225
|
+
query paginateOrgContribs($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
|
|
226
|
+
user(login: $login) {
|
|
227
|
+
repositoriesContributedTo(first: $pageSize, contributionTypes: [COMMIT, PULL_REQUEST, REPOSITORY], includeUserRepositories: false, after: $cursor) {
|
|
228
|
+
pageInfo { hasNextPage endCursor }
|
|
229
|
+
nodes { nameWithOwner owner { login __typename } stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
|
|
251
230
|
}
|
|
252
231
|
}
|
|
253
232
|
}
|
|
233
|
+
`;
|
|
254
234
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
if (!map) continue;
|
|
262
|
-
for (const [name, val] of map) {
|
|
263
|
-
const cur = merged.get(name);
|
|
264
|
-
if (cur) cur.size += val.size;
|
|
265
|
-
else merged.set(name, { size: val.size, color: val.color });
|
|
235
|
+
const QUERY_PAGINATE_ORG_REPOS = `
|
|
236
|
+
query paginateOrg($login: String!, $cursor: String, $pageSize: Int!, $fetchLangs: Boolean!) {
|
|
237
|
+
organization(login: $login) {
|
|
238
|
+
repositories(first: $pageSize, isFork: false, orderBy: {direction: DESC, field: STARGAZERS}, after: $cursor) {
|
|
239
|
+
pageInfo { hasNextPage endCursor }
|
|
240
|
+
nodes { nameWithOwner stargazers { totalCount } languages(first: 10, orderBy: {field: SIZE, direction: DESC}) @include(if: $fetchLangs) { edges { size node { color name } } } }
|
|
266
241
|
}
|
|
267
242
|
}
|
|
268
|
-
return merged;
|
|
269
243
|
}
|
|
244
|
+
`;
|
|
270
245
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
.map(([name, { size, color }]) => ({ name, size, color }));
|
|
246
|
+
async function postGraphQL<T>(query: string, variables: Record<string, unknown>): Promise<T> {
|
|
247
|
+
return githubClient.request<T>(query, {
|
|
248
|
+
variables,
|
|
249
|
+
timeoutMs: FETCH_TIMEOUT_MS,
|
|
250
|
+
});
|
|
277
251
|
}
|
|
278
252
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
try {
|
|
282
|
-
res = await fetch("https://api.github.com/graphql", {
|
|
283
|
-
method: "POST",
|
|
284
|
-
headers: getHeaders(),
|
|
285
|
-
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
286
|
-
body: JSON.stringify({ query, variables }),
|
|
287
|
-
});
|
|
288
|
-
} catch (error: any) {
|
|
289
|
-
if (error?.name === "TimeoutError" || error?.name === "AbortError") {
|
|
290
|
-
throw new UpstreamError("GitHub API request timed out");
|
|
291
|
-
}
|
|
292
|
-
throw new UpstreamError("GitHub API request failed");
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
if (!res.ok) {
|
|
296
|
-
const text = await res.text();
|
|
297
|
-
throw classifyHttpError(res.status, text);
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
const body = (await res.json()) as { data?: Record<string, any>; errors?: Array<{ message: string }> };
|
|
301
|
-
if (body.errors?.length) {
|
|
302
|
-
const msg =
|
|
303
|
-
body.errors
|
|
304
|
-
.map((e) => e.message)
|
|
305
|
-
.filter(Boolean)
|
|
306
|
-
.join(" | ") || "GitHub API error";
|
|
307
|
-
throw classifyGraphQLError(msg) || new UpstreamError(msg);
|
|
308
|
-
}
|
|
253
|
+
type LangMap = Map<string, { size: number; color: string }>;
|
|
254
|
+
type LangEdge = { size: number; node: { name: string; color: string | null } };
|
|
309
255
|
|
|
310
|
-
|
|
311
|
-
|
|
256
|
+
function accLangs(edges: LangEdge[] | null | undefined, map: LangMap): void {
|
|
257
|
+
if (!edges?.length) return;
|
|
258
|
+
for (const edge of edges) {
|
|
259
|
+
if (!edge?.node?.name || !edge.size) continue;
|
|
260
|
+
const existing = map.get(edge.node.name);
|
|
261
|
+
if (existing) existing.size += edge.size;
|
|
262
|
+
else map.set(edge.node.name, { size: edge.size, color: edge.node.color ?? "#ccc" });
|
|
312
263
|
}
|
|
313
|
-
|
|
314
|
-
return body;
|
|
315
264
|
}
|
|
316
265
|
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
/** Fetches an avatar image and returns it as a base64 data URI. */
|
|
321
|
-
async function fetchAvatarDataUrl(url: string): Promise<string> {
|
|
266
|
+
async function fetchAvatarAsBase64(url: string | null | undefined): Promise<string> {
|
|
267
|
+
if (!url) return "";
|
|
322
268
|
try {
|
|
323
|
-
const
|
|
324
|
-
|
|
325
|
-
|
|
269
|
+
const target = new URL(url);
|
|
270
|
+
target.searchParams.set("s", "150");
|
|
271
|
+
const res = await fetch(target.toString(), {
|
|
272
|
+
signal: AbortSignal.timeout(AVATAR_TIMEOUT_MS),
|
|
326
273
|
});
|
|
327
274
|
if (!res.ok) return url;
|
|
275
|
+
const contentType = res.headers.get("content-type") || "image/png";
|
|
276
|
+
if (!contentType.toLowerCase().startsWith("image/")) return url;
|
|
277
|
+
const contentLength = Number(res.headers.get("content-length") || 0);
|
|
278
|
+
if (contentLength > MAX_AVATAR_BYTES) return url;
|
|
328
279
|
const buf = await res.arrayBuffer();
|
|
329
|
-
|
|
330
|
-
return `data:${
|
|
280
|
+
if (buf.byteLength > MAX_AVATAR_BYTES) return url;
|
|
281
|
+
return `data:${contentType};base64,${Buffer.from(buf).toString("base64")}`;
|
|
331
282
|
} catch {
|
|
332
|
-
return url;
|
|
283
|
+
return url;
|
|
333
284
|
}
|
|
334
285
|
}
|
|
335
286
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
287
|
+
type ContributionEntry = {
|
|
288
|
+
repository?: { owner?: { login?: string; __typename?: string } | null } | null;
|
|
289
|
+
contributions?: { totalCount?: number } | null;
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function sumContributions(
|
|
293
|
+
entries: ContributionEntry[] | null | undefined,
|
|
294
|
+
orgsSet: Set<string> | null,
|
|
295
|
+
): number {
|
|
296
|
+
if (!entries?.length) return 0;
|
|
297
|
+
let total = 0;
|
|
298
|
+
for (const entry of entries) {
|
|
299
|
+
const owner = entry?.repository?.owner;
|
|
300
|
+
if (owner?.__typename !== "Organization" || !owner?.login) continue;
|
|
301
|
+
if (orgsSet && !orgsSet.has(owner.login.toLowerCase())) continue;
|
|
302
|
+
total += entry?.contributions?.totalCount ?? 0;
|
|
303
|
+
}
|
|
304
|
+
return total;
|
|
341
305
|
}
|
|
342
306
|
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
307
|
+
async function directFetch(username: string, opts: FetchOptions): Promise<ProfileData> {
|
|
308
|
+
const isPersonal = opts.scope !== "org";
|
|
309
|
+
const isOrg = opts.scope === "org" || opts.scope === "all";
|
|
310
|
+
const fetchLangs = opts.includeLanguages ?? true;
|
|
311
|
+
const orgsArr = normalizeOrgFilters(opts.orgs);
|
|
312
|
+
const orgsSet = orgsArr.length > 0 ? new Set(orgsArr) : null;
|
|
313
|
+
const affiliations =
|
|
314
|
+
opts.affiliations === "owner" ? ["OWNER"] : ["OWNER", "ORGANIZATION_MEMBER", "COLLABORATOR"];
|
|
315
|
+
|
|
316
|
+
const now = new Date();
|
|
317
|
+
const from = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)).toISOString();
|
|
318
|
+
const to = now.toISOString();
|
|
319
|
+
|
|
320
|
+
let user: ProfileUser | OrgProfile | null = null;
|
|
321
|
+
let isOrgAccount = false;
|
|
322
|
+
try {
|
|
323
|
+
const data = await postGraphQL<{ user: ProfileUser | null }>(QUERY_PROFILE, {
|
|
324
|
+
login: username,
|
|
325
|
+
from,
|
|
326
|
+
to,
|
|
327
|
+
isOrg,
|
|
328
|
+
});
|
|
329
|
+
user = data.user;
|
|
330
|
+
if (!user) throw new NotFoundError();
|
|
331
|
+
} catch (err) {
|
|
332
|
+
if (err instanceof NotFoundError) {
|
|
333
|
+
const data = await postGraphQL<{ organization: OrgProfile | null }>(QUERY_ORG, {
|
|
334
|
+
login: username,
|
|
335
|
+
});
|
|
336
|
+
user = data.organization;
|
|
337
|
+
if (!user) throw new NotFoundError();
|
|
338
|
+
isOrgAccount = true;
|
|
339
|
+
} else throw err;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const langMap: LangMap = new Map();
|
|
343
|
+
const seenRepos = new Set<string>();
|
|
355
344
|
let stars = 0;
|
|
356
|
-
let
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
345
|
+
let reposCount = 0;
|
|
346
|
+
|
|
347
|
+
const processNode = (node: RepoNode, scopeCheck: boolean): void => {
|
|
348
|
+
if (!node?.nameWithOwner || seenRepos.has(node.nameWithOwner)) return;
|
|
349
|
+
if (
|
|
350
|
+
scopeCheck &&
|
|
351
|
+
node.owner?.__typename === "Organization" &&
|
|
352
|
+
orgsSet &&
|
|
353
|
+
!orgsSet.has(node.owner.login.toLowerCase())
|
|
354
|
+
)
|
|
355
|
+
return;
|
|
356
|
+
seenRepos.add(node.nameWithOwner);
|
|
357
|
+
stars += node.stargazers?.totalCount ?? 0;
|
|
358
|
+
reposCount++;
|
|
359
|
+
if (fetchLangs) accLangs(node.languages?.edges ?? null, langMap);
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
type PaginateData = {
|
|
363
|
+
user?: { repositories?: RepoConnection; repositoriesContributedTo?: RepoConnection } | null;
|
|
364
|
+
organization?: { repositories: RepoConnection } | null;
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const getConnection = (data: PaginateData, query: string): RepoConnection | undefined =>
|
|
368
|
+
data.user
|
|
369
|
+
? query.includes("repositoriesContributedTo")
|
|
370
|
+
? (data.user.repositoriesContributedTo ?? undefined)
|
|
371
|
+
: (data.user.repositories ?? undefined)
|
|
372
|
+
: data.organization?.repositories;
|
|
373
|
+
|
|
374
|
+
const traversePages = async (query: string, scopeCheck: boolean): Promise<void> => {
|
|
375
|
+
let cursor: string | null = null;
|
|
376
|
+
let page = 0;
|
|
377
|
+
let hasNext = true;
|
|
378
|
+
|
|
379
|
+
while (hasNext && page++ < MAX_PAGES) {
|
|
380
|
+
const data = await postGraphQL<PaginateData>(query, {
|
|
381
|
+
login: username,
|
|
382
|
+
cursor,
|
|
383
|
+
pageSize: REPO_PAGE_SIZE,
|
|
384
|
+
fetchLangs,
|
|
385
|
+
affiliations,
|
|
386
|
+
});
|
|
387
|
+
const nextConn = getConnection(data, query);
|
|
388
|
+
for (const node of nextConn?.nodes ?? []) processNode(node, scopeCheck);
|
|
389
|
+
hasNext = nextConn?.pageInfo?.hasNextPage ?? false;
|
|
390
|
+
cursor = nextConn?.pageInfo?.endCursor ?? null;
|
|
391
|
+
if (hasNext && !cursor) break;
|
|
369
392
|
}
|
|
393
|
+
};
|
|
370
394
|
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
if (
|
|
395
|
+
const tasks: Promise<void>[] = [];
|
|
396
|
+
if (!isOrgAccount) {
|
|
397
|
+
if (isPersonal) tasks.push(traversePages(QUERY_PAGINATE_USER_REPOS, false));
|
|
398
|
+
if (isOrg) tasks.push(traversePages(QUERY_PAGINATE_ORG_CONTRIBS, true));
|
|
399
|
+
} else {
|
|
400
|
+
tasks.push(traversePages(QUERY_PAGINATE_ORG_REPOS, false));
|
|
374
401
|
}
|
|
375
402
|
|
|
376
|
-
|
|
377
|
-
}
|
|
403
|
+
const [avatarDataUrl] = await Promise.all([fetchAvatarAsBase64(user.avatarUrl), ...tasks]);
|
|
378
404
|
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
* Filters by org login when `orgsFilter` is non-empty.
|
|
383
|
-
*/
|
|
384
|
-
async function fetchOrgContributions(
|
|
385
|
-
username: string,
|
|
386
|
-
langs: boolean,
|
|
387
|
-
orgsFilter: string[],
|
|
388
|
-
): Promise<RepoResult> {
|
|
389
|
-
const query = buildOrgContribsQuery(langs);
|
|
390
|
-
const langMap: LangMap | null = langs ? new Map() : null;
|
|
391
|
-
const filterSet = orgsFilter.length > 0 ? new Set(orgsFilter) : null;
|
|
392
|
-
let hasNext = true;
|
|
393
|
-
let cursor: string | null = null;
|
|
394
|
-
let stars = 0;
|
|
395
|
-
let repos = 0;
|
|
396
|
-
let page = 0;
|
|
397
|
-
|
|
398
|
-
while (hasNext && page++ < MAX_PAGES) {
|
|
399
|
-
const body = await postGraphQL(query, { login: username, cursor });
|
|
400
|
-
if (!body.data?.user) throw new NotFoundError("User not found");
|
|
401
|
-
|
|
402
|
-
const c = body.data.user.repositoriesContributedTo;
|
|
403
|
-
for (const repo of c?.nodes || []) {
|
|
404
|
-
if (repo?.owner?.__typename !== "Organization") continue;
|
|
405
|
-
if (filterSet && !filterSet.has(repo.owner.login.toLowerCase())) continue;
|
|
406
|
-
stars += repo.stargazers?.totalCount || 0;
|
|
407
|
-
repos += 1;
|
|
408
|
-
if (langMap) accumulateLanguageEdges(repo.languages?.edges || [], langMap);
|
|
409
|
-
}
|
|
405
|
+
let commits = 0;
|
|
406
|
+
let prs = 0;
|
|
407
|
+
let issues = 0;
|
|
410
408
|
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
409
|
+
if (!isOrgAccount) {
|
|
410
|
+
const profile = user as ProfileUser;
|
|
411
|
+
const c = profile.contributionsCollection;
|
|
412
|
+
if (opts.scope === "org") {
|
|
413
|
+
commits = sumContributions(c?.commitContributionsByRepository, orgsSet);
|
|
414
|
+
prs = sumContributions(c?.pullRequestContributionsByRepository, orgsSet);
|
|
415
|
+
issues = sumContributions(c?.issueContributionsByRepository, orgsSet);
|
|
416
|
+
} else {
|
|
417
|
+
commits = c?.totalCommitContributions ?? 0;
|
|
418
|
+
prs =
|
|
419
|
+
(profile.openPRs?.totalCount ?? 0) +
|
|
420
|
+
(profile.closedPRs?.totalCount ?? 0) +
|
|
421
|
+
(profile.mergedPRs?.totalCount ?? 0);
|
|
422
|
+
issues = (profile.openIssues?.totalCount ?? 0) + (profile.closedIssues?.totalCount ?? 0);
|
|
423
|
+
}
|
|
414
424
|
}
|
|
415
425
|
|
|
416
|
-
|
|
426
|
+
const languages = Array.from(langMap.entries())
|
|
427
|
+
.map(([name, { size, color }]) => ({ name, size, color }))
|
|
428
|
+
.sort((a, b) => b.size - a.size)
|
|
429
|
+
.slice(0, opts.langCount || 5);
|
|
430
|
+
|
|
431
|
+
const profileUser = !isOrgAccount ? (user as ProfileUser) : null;
|
|
432
|
+
const orgUser = isOrgAccount ? (user as OrgProfile) : null;
|
|
433
|
+
|
|
434
|
+
return {
|
|
435
|
+
user: {
|
|
436
|
+
login: user!.login,
|
|
437
|
+
name: user!.name,
|
|
438
|
+
avatarUrl: avatarDataUrl,
|
|
439
|
+
bio: isOrgAccount ? (orgUser?.description ?? null) : (profileUser?.bio ?? null),
|
|
440
|
+
pronouns: isOrgAccount ? null : (profileUser?.pronouns ?? null),
|
|
441
|
+
twitter: user!.twitterUsername ?? null,
|
|
442
|
+
},
|
|
443
|
+
stats: { stars, repos: reposCount, prs, issues, commits },
|
|
444
|
+
languages,
|
|
445
|
+
};
|
|
417
446
|
}
|
|
418
447
|
|
|
419
448
|
export async function getProfileData(
|
|
420
449
|
username: string,
|
|
421
450
|
opts: FetchOptions = {},
|
|
422
451
|
): Promise<ProfileData> {
|
|
423
|
-
const
|
|
424
|
-
const
|
|
425
|
-
const
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
if (cached?.stale) {
|
|
438
|
-
const existingStale = cache.get(cacheKey);
|
|
439
|
-
if (existingStale && !existingStale.inFlight) {
|
|
440
|
-
existingStale.inFlight = (async () => {
|
|
441
|
-
try {
|
|
442
|
-
const refreshed = await getProfileData(username, {
|
|
443
|
-
includeLanguages,
|
|
444
|
-
langCount,
|
|
445
|
-
scope,
|
|
446
|
-
orgs,
|
|
447
|
-
forceRefresh: true,
|
|
448
|
-
});
|
|
449
|
-
return refreshed;
|
|
450
|
-
} catch {
|
|
451
|
-
return cached.value;
|
|
452
|
-
} finally {
|
|
453
|
-
const current = cache.get(cacheKey);
|
|
454
|
-
if (current) current.inFlight = undefined;
|
|
455
|
-
}
|
|
456
|
-
})();
|
|
457
|
-
}
|
|
458
|
-
return cached.value;
|
|
452
|
+
const norm = normalizeUsername(username);
|
|
453
|
+
const orgs = normalizeOrgFilters(opts.orgs);
|
|
454
|
+
const cacheKey = Bun.hash(
|
|
455
|
+
`v${CACHE_VERSION}:${norm}:${opts.scope || "personal"}:${opts.affiliations || "owner"}:${opts.includeLanguages !== false}:${opts.langCount || 5}:${orgs.join("|")}`,
|
|
456
|
+
).toString(36);
|
|
457
|
+
|
|
458
|
+
if (!opts.forceRefresh) {
|
|
459
|
+
const mem = memCache.get(cacheKey);
|
|
460
|
+
const now = Date.now();
|
|
461
|
+
if (mem && mem.expiresAt > now) {
|
|
462
|
+
if (mem.staleAt <= now && !refreshInFlight.has(cacheKey))
|
|
463
|
+
triggerBackgroundRefresh(username, opts, cacheKey);
|
|
464
|
+
return mem.data;
|
|
459
465
|
}
|
|
460
|
-
}
|
|
461
466
|
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
}
|
|
470
|
-
} catch {}
|
|
467
|
+
const cached = await readRedisCache(cacheKey, now);
|
|
468
|
+
if (cached) {
|
|
469
|
+
setMemoryCache(cacheKey, cached);
|
|
470
|
+
if (cached.staleAt <= now && !refreshInFlight.has(cacheKey))
|
|
471
|
+
triggerBackgroundRefresh(username, opts, cacheKey);
|
|
472
|
+
return cached.data;
|
|
473
|
+
}
|
|
471
474
|
}
|
|
472
475
|
|
|
473
|
-
|
|
474
|
-
if (!forceRefresh && existing?.inFlight) return existing.inFlight;
|
|
475
|
-
|
|
476
|
-
const inFlight = (async () => {
|
|
477
|
-
try {
|
|
478
|
-
const now = new Date();
|
|
479
|
-
const from = new Date(Date.UTC(now.getUTCFullYear(), 0, 1)).toISOString();
|
|
480
|
-
const to = now.toISOString();
|
|
481
|
-
|
|
482
|
-
// Parallel data fetching based on scope
|
|
483
|
-
let user: any;
|
|
484
|
-
let pStars = 0,
|
|
485
|
-
pRepos = 0,
|
|
486
|
-
oStars = 0,
|
|
487
|
-
oRepos = 0;
|
|
488
|
-
let pLangs: LangMap | null = null;
|
|
489
|
-
let oLangs: LangMap | null = null;
|
|
490
|
-
|
|
491
|
-
if (scope === "personal") {
|
|
492
|
-
const r = await fetchPersonalRepos(username, includeLanguages, from, to);
|
|
493
|
-
user = r.user;
|
|
494
|
-
pStars = r.stars;
|
|
495
|
-
pRepos = r.repos;
|
|
496
|
-
pLangs = r.langMap;
|
|
497
|
-
} else if (scope === "org") {
|
|
498
|
-
const [meta, org] = await Promise.all([
|
|
499
|
-
fetchUserMeta(username, from, to),
|
|
500
|
-
fetchOrgContributions(username, includeLanguages, orgs),
|
|
501
|
-
]);
|
|
502
|
-
user = meta;
|
|
503
|
-
oStars = org.stars;
|
|
504
|
-
oRepos = org.repos;
|
|
505
|
-
oLangs = org.langMap;
|
|
506
|
-
} else {
|
|
507
|
-
// scope === "all" — fetch personal + org in parallel
|
|
508
|
-
const [personal, org] = await Promise.all([
|
|
509
|
-
fetchPersonalRepos(username, includeLanguages, from, to),
|
|
510
|
-
fetchOrgContributions(username, includeLanguages, orgs),
|
|
511
|
-
]);
|
|
512
|
-
user = personal.user;
|
|
513
|
-
pStars = personal.stars;
|
|
514
|
-
pRepos = personal.repos;
|
|
515
|
-
pLangs = personal.langMap;
|
|
516
|
-
oStars = org.stars;
|
|
517
|
-
oRepos = org.repos;
|
|
518
|
-
oLangs = org.langMap;
|
|
519
|
-
}
|
|
520
|
-
|
|
521
|
-
const languages = includeLanguages
|
|
522
|
-
? buildLanguageStats(mergeLangMaps(pLangs, oLangs), langCount)
|
|
523
|
-
: [];
|
|
524
|
-
|
|
525
|
-
// Embed avatar as base64 data URI for reliable rendering in <img> contexts
|
|
526
|
-
const avatarSized = user.avatarUrl + (user.avatarUrl.includes("?") ? "&" : "?") + "s=200";
|
|
527
|
-
const avatarDataUrl = await fetchAvatarDataUrl(avatarSized);
|
|
528
|
-
|
|
529
|
-
const profile: ProfileData = {
|
|
530
|
-
user: {
|
|
531
|
-
login: user.login,
|
|
532
|
-
name: user.name,
|
|
533
|
-
avatarUrl: avatarDataUrl,
|
|
534
|
-
bio: user.bio,
|
|
535
|
-
pronouns: user.pronouns,
|
|
536
|
-
twitter: user.twitterUsername,
|
|
537
|
-
},
|
|
538
|
-
stats: {
|
|
539
|
-
stars: scope === "org" ? oStars : pStars + oStars,
|
|
540
|
-
repos: scope === "org" ? oRepos : pRepos + oRepos,
|
|
541
|
-
prs:
|
|
542
|
-
(user.openPRs?.totalCount || 0) +
|
|
543
|
-
(user.closedPRs?.totalCount || 0) +
|
|
544
|
-
(user.mergedPRs?.totalCount || 0),
|
|
545
|
-
issues: (user.openIssues?.totalCount || 0) + (user.closedIssues?.totalCount || 0),
|
|
546
|
-
commits: user.contributionsCollection?.totalCommitContributions || 0,
|
|
547
|
-
},
|
|
548
|
-
languages,
|
|
549
|
-
};
|
|
550
|
-
|
|
551
|
-
setCache(cacheKey, profile);
|
|
552
|
-
if (redis) {
|
|
553
|
-
try {
|
|
554
|
-
await redis.set(`profile:${cacheKey}`, profile, {
|
|
555
|
-
ex: CACHE_FRESH_SECONDS + CACHE_STALE_SECONDS,
|
|
556
|
-
});
|
|
557
|
-
} catch {}
|
|
558
|
-
}
|
|
559
|
-
return profile;
|
|
560
|
-
} catch (err) {
|
|
561
|
-
cache.delete(cacheKey);
|
|
562
|
-
throw err;
|
|
563
|
-
}
|
|
564
|
-
})();
|
|
476
|
+
if (inFlight.has(cacheKey)) return inFlight.get(cacheKey)!;
|
|
565
477
|
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
478
|
+
const promise = directFetch(username, opts)
|
|
479
|
+
.then((data) => saveProfileCache(cacheKey, data))
|
|
480
|
+
.finally(() => {
|
|
481
|
+
inFlight.delete(cacheKey);
|
|
482
|
+
});
|
|
483
|
+
|
|
484
|
+
inFlight.set(cacheKey, promise);
|
|
485
|
+
return promise;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
function triggerBackgroundRefresh(username: string, opts: FetchOptions, cacheKey: string) {
|
|
489
|
+
if (refreshInFlight.has(cacheKey)) return;
|
|
490
|
+
refreshInFlight.add(cacheKey);
|
|
491
|
+
|
|
492
|
+
void directFetch(username, { ...opts, forceRefresh: true })
|
|
493
|
+
.then((data) => saveProfileCache(cacheKey, data))
|
|
494
|
+
.catch(() => {})
|
|
495
|
+
.finally(() => {
|
|
496
|
+
refreshInFlight.delete(cacheKey);
|
|
497
|
+
});
|
|
572
498
|
}
|