Skip to content

Commit 63c4a90

Browse files
committed
update
1 parent 9b59061 commit 63c4a90

2 files changed

Lines changed: 132 additions & 152 deletions

File tree

packages/app/app/components/RepoSearch.vue

Lines changed: 23 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -5,58 +5,47 @@ const search = useSessionStorage("search", "");
55
const searchResults = ref<RepoNode[]>([]);
66
const isLoading = ref(false);
77
8-
let eventSource: EventSource | null = null;
8+
let abortController: AbortController | null = null;
99
const throttledSearch = useThrottle(search, 500, true, false);
1010
11-
function closeEventSource(source: EventSource) {
12-
source.close();
13-
if (eventSource === source) {
14-
eventSource = null;
15-
isLoading.value = false;
16-
}
17-
}
18-
1911
watch(
2012
throttledSearch,
2113
async (query) => {
22-
if (eventSource) {
23-
closeEventSource(eventSource);
24-
}
14+
abortController?.abort();
2515
searchResults.value = [];
2616
2717
if (!query) {
2818
isLoading.value = false;
2919
return;
3020
}
3121
22+
const controller = new AbortController();
23+
abortController = controller;
3224
isLoading.value = true;
33-
const source = new EventSource(
34-
`/api/repo/search?text=${encodeURIComponent(query)}`,
35-
);
36-
eventSource = source;
3725
38-
source.onmessage = (event) => {
39-
if (event.data === "[DONE]") {
40-
closeEventSource(source);
26+
try {
27+
const response = await fetch(
28+
`/api/repo/search?text=${encodeURIComponent(query)}`,
29+
{ signal: controller.signal },
30+
);
31+
const data = await response.json();
32+
33+
if (abortController !== controller) {
4134
return;
4235
}
4336
44-
try {
45-
const repo = JSON.parse(event.data) as RepoNode & { error?: string };
46-
if (repo.error) {
47-
return;
48-
}
49-
50-
searchResults.value.push(repo);
51-
} catch {
52-
// Skip malformed JSON
37+
searchResults.value = Array.isArray(data?.nodes)
38+
? (data.nodes as RepoNode[])
39+
: [];
40+
} catch (err: any) {
41+
if (err.name !== "AbortError") {
42+
console.error(err);
5343
}
54-
};
55-
56-
source.onerror = (err) => {
57-
console.error(err);
58-
closeEventSource(source);
59-
};
44+
} finally {
45+
if (abortController === controller) {
46+
isLoading.value = false;
47+
}
48+
}
6049
},
6150
{ immediate: false },
6251
);
Lines changed: 109 additions & 118 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { H3Event } from "h3";
12
import stringSimilarity from "string-similarity";
23
import { z } from "zod";
34
import { useBucket } from "../../utils/bucket";
@@ -23,96 +24,104 @@ interface RepoSearchIndexCache {
2324
repos: RepoSearchIndexItem[];
2425
}
2526

26-
export default defineEventHandler(async (event) => {
27-
const query = await getValidatedQuery(event, (data) =>
28-
querySchema.parse(data),
29-
);
30-
if (!query.text) {
31-
return { nodes: [] };
32-
}
27+
type CacheStatus = "hit" | "stale" | "miss";
3328

34-
const { signal } = toWebRequest(event);
35-
const searchText = query.text.toLowerCase();
36-
const app = useOctokitApp(event);
37-
const bucket = useBucket(event);
29+
let revalidateRepoIndexPromise: Promise<void> | null = null;
3830

39-
const fetchInstalledRepos = async () => {
40-
const seen = new Set<number>();
41-
const repos: RepoSearchIndexItem[] = [];
42-
43-
for await (const { installation } of app.eachInstallation.iterator()) {
44-
if (signal.aborted) {
45-
break;
46-
}
31+
async function fetchInstalledRepos(event: H3Event) {
32+
const app = useOctokitApp(event);
33+
const seen = new Set<number>();
34+
const repos: RepoSearchIndexItem[] = [];
35+
36+
for await (const { installation } of app.eachInstallation.iterator()) {
37+
try {
38+
const octokit = await app.getInstallationOctokit(installation.id);
39+
const installationRepos = await octokit.paginate(
40+
"GET /installation/repositories",
41+
{ per_page: 100 },
42+
(response) => response.data.repositories,
43+
);
4744

48-
try {
49-
const octokit = await app.getInstallationOctokit(installation.id);
50-
let page = 1;
51-
52-
while (true) {
53-
const { data } = await octokit.request(
54-
"GET /installation/repositories",
55-
{
56-
page,
57-
per_page: 100,
58-
},
59-
);
60-
61-
for (const repo of data.repositories) {
62-
if (repo.private || seen.has(repo.id)) {
63-
continue;
64-
}
65-
seen.add(repo.id);
66-
repos.push({
67-
id: repo.id,
68-
name: repo.name,
69-
ownerLogin: repo.owner.login,
70-
ownerAvatarUrl: repo.owner.avatar_url,
71-
stars: repo.stargazers_count || 0,
72-
});
73-
}
74-
75-
if (data.repositories.length < 100) {
76-
break;
77-
}
78-
page += 1;
45+
for (const repo of installationRepos) {
46+
if (repo.private || seen.has(repo.id)) {
47+
continue;
7948
}
80-
} catch {
81-
// Skip suspended installations
49+
50+
seen.add(repo.id);
51+
repos.push({
52+
id: repo.id,
53+
name: repo.name,
54+
ownerLogin: repo.owner.login,
55+
ownerAvatarUrl: repo.owner.avatar_url,
56+
stars: repo.stargazers_count || 0,
57+
});
8258
}
59+
} catch {
60+
// Skip suspended installations
8361
}
62+
}
8463

85-
return repos;
86-
};
64+
return repos;
65+
}
8766

88-
const getIndexedRepos = async () => {
89-
const now = Date.now();
90-
const cached =
91-
await bucket.getItem<RepoSearchIndexCache>(REPO_INDEX_CACHE_KEY);
67+
async function revalidateRepoIndex(event: H3Event) {
68+
if (revalidateRepoIndexPromise) {
69+
return revalidateRepoIndexPromise;
70+
}
9271

93-
if (cached && now - cached.fetchedAt < REPO_INDEX_CACHE_TTL_MS) {
94-
return {
95-
repos: cached.repos,
96-
cacheStatus: "hit" as const,
97-
};
98-
}
72+
revalidateRepoIndexPromise = (async () => {
73+
const bucket = useBucket(event);
74+
const repos = await fetchInstalledRepos(event);
75+
76+
await bucket.setItem(REPO_INDEX_CACHE_KEY, {
77+
fetchedAt: Date.now(),
78+
repos,
79+
});
80+
})().finally(() => {
81+
revalidateRepoIndexPromise = null;
82+
});
83+
84+
return revalidateRepoIndexPromise;
85+
}
86+
87+
async function getIndexedRepos(event: H3Event): Promise<{
88+
repos: RepoSearchIndexItem[];
89+
cacheStatus: CacheStatus;
90+
}> {
91+
const bucket = useBucket(event);
92+
const now = Date.now();
93+
const cached =
94+
await bucket.getItem<RepoSearchIndexCache>(REPO_INDEX_CACHE_KEY);
95+
96+
if (cached && now - cached.fetchedAt < REPO_INDEX_CACHE_TTL_MS) {
97+
return { repos: cached.repos, cacheStatus: "hit" };
98+
}
9999

100-
const repos = await fetchInstalledRepos();
101-
if (!signal.aborted) {
102-
await bucket.setItem(REPO_INDEX_CACHE_KEY, {
103-
fetchedAt: now,
104-
repos,
105-
});
100+
if (cached) {
101+
const refreshPromise = revalidateRepoIndex(event).catch((err) => {
102+
console.error("Failed to refresh repo index cache", err);
103+
});
104+
105+
if (typeof event.waitUntil === "function") {
106+
event.waitUntil(refreshPromise);
106107
}
107108

108-
return {
109-
repos,
110-
cacheStatus: "miss" as const,
111-
};
112-
};
109+
return { repos: cached.repos, cacheStatus: "stale" };
110+
}
111+
112+
const repos = await fetchInstalledRepos(event);
113+
await bucket.setItem(REPO_INDEX_CACHE_KEY, {
114+
fetchedAt: now,
115+
repos,
116+
});
113117

114-
const { repos, cacheStatus } = await getIndexedRepos();
115-
const matches = repos
118+
return { repos, cacheStatus: "miss" };
119+
}
120+
121+
function findMatches(repos: RepoSearchIndexItem[], text: string) {
122+
const searchText = text.toLowerCase();
123+
124+
return repos
116125
.map((repo) => {
117126
const name = repo.name.toLowerCase();
118127
const owner = repo.ownerLogin.toLowerCase();
@@ -130,52 +139,34 @@ export default defineEventHandler(async (event) => {
130139
}
131140

132141
return {
133-
...repo,
142+
id: repo.id,
143+
name: repo.name,
144+
owner: {
145+
login: repo.ownerLogin,
146+
avatarUrl: repo.ownerAvatarUrl,
147+
},
148+
stars: repo.stars,
134149
score,
135150
};
136151
})
137-
.filter((repo): repo is RepoSearchIndexItem & { score: number } => !!repo)
138-
.sort((a, b) => b.score - a.score || b.stars - a.stars);
139-
140-
setResponseHeaders(event, {
141-
"Content-Type": "text/event-stream",
142-
"Cache-Control": "no-cache",
143-
Connection: "keep-alive",
144-
"x-repo-index-cache": cacheStatus,
145-
});
152+
.filter((repo): repo is NonNullable<typeof repo> => !!repo)
153+
.sort((a, b) => b.score - a.score || b.stars - a.stars)
154+
.map(({ score: _score, ...repo }) => repo);
155+
}
146156

147-
const stream = new ReadableStream<string>({
148-
start(controller) {
149-
const send = (data: string) => {
150-
controller.enqueue(`data: ${data}\n\n`);
151-
};
157+
export default defineEventHandler(async (event) => {
158+
const query = await getValidatedQuery(event, (data) =>
159+
querySchema.parse(data),
160+
);
152161

153-
try {
154-
for (const repo of matches) {
155-
if (signal.aborted) {
156-
break;
157-
}
158-
send(
159-
JSON.stringify({
160-
id: repo.id,
161-
name: repo.name,
162-
owner: {
163-
login: repo.ownerLogin,
164-
avatarUrl: repo.ownerAvatarUrl,
165-
},
166-
stars: repo.stars,
167-
}),
168-
);
169-
}
162+
if (!query.text) {
163+
return { nodes: [] };
164+
}
170165

171-
send("[DONE]");
172-
} catch (err) {
173-
send(JSON.stringify({ error: (err as Error).message }));
174-
} finally {
175-
controller.close();
176-
}
177-
},
178-
});
166+
const { repos, cacheStatus } = await getIndexedRepos(event);
167+
setResponseHeader(event, "x-repo-index-cache", cacheStatus);
179168

180-
return stream.pipeThrough(new TextEncoderStream());
169+
return {
170+
nodes: findMatches(repos, query.text),
171+
};
181172
});

0 commit comments

Comments
 (0)