From fed51cc2113c0b580a4c8b79b823b6c7bedfd910 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sun, 30 Nov 2025 18:53:35 +0330 Subject: [PATCH 1/6] init --- packages/app/server/api/repo/search.get.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index eb8764dc..0c873fd0 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -7,6 +7,8 @@ const querySchema = z.object({ text: z.string(), }); +console.log("querySchema", querySchema); + interface SearchDebugInfo { startTime: string; endTime: string; From 907386030a39cb215b13768ff78097f54db01b16 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sun, 30 Nov 2025 19:29:47 +0330 Subject: [PATCH 2/6] stream --- packages/app/app/components/RepoSearch.vue | 50 +++- packages/app/server/api/repo/search.get.ts | 328 +++++---------------- 2 files changed, 127 insertions(+), 251 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index 6b5a971d..54d22d9a 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -24,11 +24,53 @@ watch( try { const response = await fetch( `/api/repo/search?text=${encodeURIComponent(newValue)}`, - { signal: activeController.signal }, + { signal: controller.signal }, ); - const data = (await response.json()) as { nodes: RepoNode[] }; - if (activeController === controller) { - searchResults.value = data.nodes ?? []; + + const reader = response.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6); + + if (data === "[DONE]") { + isLoading.value = false; + return; + } + + try { + const repo = JSON.parse(data) as RepoNode & { error?: string }; + if (!repo.error) { + // Insert sorted by stars (higher first) + const idx = searchResults.value.findIndex( + (r) => r.stars < repo.stars, + ); + if (idx === -1) { + searchResults.value.push(repo); + } else { + searchResults.value.splice(idx, 0, repo); + } + // Keep only top 10 + if (searchResults.value.length > 10) { + searchResults.value.pop(); + } + } + } catch { + // Skip malformed JSON + } + } } } catch (err: any) { if (err.name !== "AbortError") { diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index 0c873fd0..b990cbb9 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -7,263 +7,97 @@ const querySchema = z.object({ text: z.string(), }); -console.log("querySchema", querySchema); - -interface SearchDebugInfo { - startTime: string; - endTime: string; - totalElapsedMs: number; - processedRepositories: number; - matchesFound: number; - averageProcessingTimePerRepo: number; - repositoriesPerSecond: number; - searchQuery: string; - status: "completed" | "aborted" | "error"; - flowErrors: Array<{ - stage: string; - error: string; - timestamp: string; - repositoryContext?: string; - }>; - flowStages: Array<{ - stage: string; - timestamp: string; - details?: string; - }>; -} - export default defineEventHandler(async (event) => { - const request = toWebRequest(event); - const signal = request.signal; - - const flowErrors: SearchDebugInfo["flowErrors"] = []; - const flowStages: SearchDebugInfo["flowStages"] = []; - - const addFlowStage = (stage: string, details?: string) => { - flowStages.push({ - stage, - timestamp: new Date().toISOString(), - details, - }); - }; - - const addFlowError = ( - stage: string, - error: string, - repositoryContext?: string, - ) => { - flowErrors.push({ - stage, - error, - timestamp: new Date().toISOString(), - repositoryContext, - }); - }; - - try { - addFlowStage("query_validation", "Starting query validation"); - - const query = await getValidatedQuery(event, (data) => - querySchema.parse(data), - ); - - if (!query.text) { - addFlowStage("early_return", "Empty query text"); - return { nodes: [], debug: null }; - } - - addFlowStage("query_validated", `Query: "${query.text}"`); - - let app; - try { - addFlowStage("octokit_init", "Initializing Octokit app"); - app = useOctokitApp(event); - addFlowStage("octokit_ready", "Octokit app initialized successfully"); - } catch (err: any) { - addFlowError("octokit_init", err.message); - throw new Error(`Failed to initialize Octokit: ${err.message}`); - } + const query = await getValidatedQuery(event, (data) => + querySchema.parse(data), + ); - const searchText = query.text.toLowerCase(); - const matches: RepoNode[] = []; - const startTime = Date.now(); - let processedRepositories = 0; - let status: SearchDebugInfo["status"] = "completed"; - let skippedRepositories = 0; - let suspendedErrors = 0; - - addFlowStage( - "repository_iteration_start", - `Starting to iterate repositories for search: "${searchText}"`, - ); - - try { - await app.eachRepository(async ({ repository }) => { - try { - if (signal.aborted) { - addFlowStage( - "search_aborted", - `Aborted at repository: ${repository.full_name}`, - ); - status = "aborted"; - return; - } - - if (repository.private) { - skippedRepositories++; - return; - } - - processedRepositories++; - - // Add periodic progress tracking - if (processedRepositories % 100 === 0) { - const elapsed = Date.now() - startTime; - addFlowStage( - "progress_checkpoint", - `Processed ${processedRepositories} repositories in ${elapsed}ms`, - ); - } - - const repoName = repository.name.toLowerCase(); - const ownerLogin = repository.owner.login.toLowerCase(); + if (!query.text) { + return { nodes: [] }; + } - let nameScore, ownerScore; + // Set SSE headers + setResponseHeaders(event, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + const app = useOctokitApp(event); + const searchText = query.text.toLowerCase(); + const seen = new Set(); + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + const sendResult = (repo: Omit) => { + if (seen.has(repo.id)) return; + seen.add(repo.id); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(repo)}\n\n`)); + }; + + const sendDone = () => { + controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); + controller.close(); + }; + + try { + for await (const { installation } of app.eachInstallation.iterator()) { try { - nameScore = stringSimilarity.compareTwoStrings( - repoName, - searchText, - ); - ownerScore = stringSimilarity.compareTwoStrings( - ownerLogin, - searchText, - ); - } catch (err: any) { - addFlowError( - "string_similarity", - err.message, - repository.full_name, - ); - // Use fallback scoring - nameScore = repoName.includes(searchText) ? 0.5 : 0; - ownerScore = ownerLogin.includes(searchText) ? 0.5 : 0; - } + const octokit = await app.getInstallationOctokit(installation.id); - try { - matches.push({ - id: repository.id, - name: repository.name, - owner: { - login: repository.owner.login, - avatarUrl: repository.owner.avatar_url, - }, - stars: repository.stargazers_count || 0, - score: Math.max(nameScore, ownerScore), - }); - } catch (err: any) { - addFlowError("match_creation", err.message, repository.full_name); - } - } catch (err: any) { - if ( - err.message?.includes("suspended") || - err.message?.includes("Installation") - ) { - suspendedErrors++; - addFlowError( - "repository_suspended", - err.message, - repository.full_name, + const { data } = await octokit.request( + "GET /installation/repositories", + { per_page: 100 }, ); - return; + + for (const repo of data.repositories) { + if (repo.private) continue; + + const nameScore = stringSimilarity.compareTwoStrings( + repo.name.toLowerCase(), + searchText, + ); + const ownerScore = stringSimilarity.compareTwoStrings( + repo.owner.login.toLowerCase(), + searchText, + ); + const score = Math.max(nameScore, ownerScore); + + // Only send if it's a decent match + if ( + score > 0.3 || + repo.name.toLowerCase().includes(searchText) || + repo.owner.login.toLowerCase().includes(searchText) + ) { + sendResult({ + id: repo.id, + name: repo.name, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url, + }, + stars: repo.stargazers_count || 0, + }); + } + } + } catch { + // Skip suspended installations } - addFlowError( - "repository_processing", - err.message, - repository.full_name, - ); - throw err; } - }); - addFlowStage( - "repository_iteration_complete", - `Completed repository iteration`, - ); - } catch (err: any) { - if ( - err.message?.includes("suspended") || - err.message?.includes("Installation") - ) { - addFlowError( - "iteration_suspended", - `Installation suspended after processing ${processedRepositories} repositories: ${err.message}`, + sendDone(); + } catch (err) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ error: (err as Error).message })}\n\n`, + ), ); - status = "completed"; - } else { - addFlowError("iteration_failed", err.message); - status = "error"; - throw err; + controller.close(); } - } - - const totalElapsed = Date.now() - startTime; - - addFlowStage("sorting_matches", `Sorting ${matches.length} matches`); - - try { - matches.sort((a, b) => - b.score !== a.score ? b.score - a.score : b.stars - a.stars, - ); - addFlowStage("sorting_complete", "Matches sorted successfully"); - } catch (err: any) { - addFlowError("sorting", err.message); - } - - const top = matches.slice(0, 10).map((node) => ({ - id: node.id, - name: node.name, - owner: node.owner, - stars: node.stars, - })); + }, + }); - addFlowStage("response_preparation", `Prepared ${top.length} top results`); - - const debugInfo: SearchDebugInfo = { - startTime: new Date(startTime).toISOString(), - endTime: new Date().toISOString(), - totalElapsedMs: totalElapsed, - processedRepositories, - matchesFound: matches.length, - averageProcessingTimePerRepo: - processedRepositories > 0 ? totalElapsed / processedRepositories : 0, - repositoriesPerSecond: processedRepositories / (totalElapsed / 1000), - searchQuery: query.text, - status, - flowErrors, - flowStages, - }; - - return { nodes: top, debug: debugInfo }; - } catch (error) { - addFlowError("global_error", (error as Error).message); - - return { - nodes: [], - error: true, - message: (error as Error).message, - debug: { - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - totalElapsedMs: 0, - processedRepositories: 0, - matchesFound: 0, - averageProcessingTimePerRepo: 0, - repositoriesPerSecond: 0, - searchQuery: "", - status: "error" as const, - flowErrors, - flowStages, - }, - }; - } + return stream; }); From ca7366e2f4ca37e5717822a64ad77cd7eb3ff379 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sun, 30 Nov 2025 19:40:27 +0330 Subject: [PATCH 3/6] fix: add abort controller --- packages/app/app/components/RepoSearch.vue | 3 +++ packages/app/server/api/repo/search.get.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index 54d22d9a..b6e1ee3c 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -5,12 +5,14 @@ const searchResults = ref([]); const isLoading = ref(false); let activeController: AbortController | null = null; +let activeReader: ReadableStreamDefaultReader | null = null; const throttledSearch = useThrottle(search, 500, true, false); watch( throttledSearch, async (newValue) => { activeController?.abort(); + activeReader?.cancel(); searchResults.value = []; if (!newValue) { isLoading.value = false; @@ -29,6 +31,7 @@ watch( const reader = response.body?.getReader(); if (!reader) return; + activeReader = reader; const decoder = new TextDecoder(); let buffer = ""; diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index b990cbb9..1796931b 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -8,6 +8,9 @@ const querySchema = z.object({ }); export default defineEventHandler(async (event) => { + const request = toWebRequest(event); + const signal = request.signal; + const query = await getValidatedQuery(event, (data) => querySchema.parse(data), ); @@ -44,6 +47,11 @@ export default defineEventHandler(async (event) => { try { for await (const { installation } of app.eachInstallation.iterator()) { + if (signal.aborted) { + controller.close(); + return; + } + try { const octokit = await app.getInstallationOctokit(installation.id); From 11064a7d6b9abf37671e1e3557eaa8ba2c2122c4 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 1 Dec 2025 10:45:00 +0330 Subject: [PATCH 4/6] refacor: simplified --- packages/app/app/components/RepoSearch.vue | 94 ++++++---------------- packages/app/server/api/repo/search.get.ts | 84 +++++++------------ 2 files changed, 53 insertions(+), 125 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index b6e1ee3c..ef21a0ec 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -1,37 +1,36 @@ @@ -154,10 +113,7 @@ function openFirstResult() { /> -
+
No repositories found
diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index 1796931b..3663038e 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -1,16 +1,12 @@ import { z } from "zod"; import { useOctokitApp } from "../../utils/octokit"; import stringSimilarity from "string-similarity"; -import type { RepoNode } from "../../utils/types"; const querySchema = z.object({ text: z.string(), }); export default defineEventHandler(async (event) => { - const request = toWebRequest(event); - const signal = request.signal; - const query = await getValidatedQuery(event, (data) => querySchema.parse(data), ); @@ -19,7 +15,8 @@ export default defineEventHandler(async (event) => { return { nodes: [] }; } - // Set SSE headers + const { signal } = toWebRequest(event); + setResponseHeaders(event, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -29,65 +26,45 @@ export default defineEventHandler(async (event) => { const app = useOctokitApp(event); const searchText = query.text.toLowerCase(); const seen = new Set(); + const encoder = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - - const sendResult = (repo: Omit) => { - if (seen.has(repo.id)) return; - seen.add(repo.id); - controller.enqueue(encoder.encode(`data: ${JSON.stringify(repo)}\n\n`)); - }; - - const sendDone = () => { - controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); - controller.close(); - }; + const send = (data: string) => encoder.encode(`data: ${data}\n\n`); + return new ReadableStream({ + async start(controller) { try { for await (const { installation } of app.eachInstallation.iterator()) { - if (signal.aborted) { - controller.close(); - return; - } + if (signal.aborted) break; try { const octokit = await app.getInstallationOctokit(installation.id); - const { data } = await octokit.request( "GET /installation/repositories", { per_page: 100 }, ); for (const repo of data.repositories) { - if (repo.private) continue; + if (repo.private || seen.has(repo.id)) continue; - const nameScore = stringSimilarity.compareTwoStrings( - repo.name.toLowerCase(), - searchText, + const name = repo.name.toLowerCase(); + const owner = repo.owner.login.toLowerCase(); + const score = Math.max( + stringSimilarity.compareTwoStrings(name, searchText), + stringSimilarity.compareTwoStrings(owner, searchText), ); - const ownerScore = stringSimilarity.compareTwoStrings( - repo.owner.login.toLowerCase(), - searchText, - ); - const score = Math.max(nameScore, ownerScore); - // Only send if it's a decent match - if ( - score > 0.3 || - repo.name.toLowerCase().includes(searchText) || - repo.owner.login.toLowerCase().includes(searchText) - ) { - sendResult({ - id: repo.id, - name: repo.name, - owner: { - login: repo.owner.login, - avatarUrl: repo.owner.avatar_url, - }, - stars: repo.stargazers_count || 0, - }); + if (score > 0.3 || name.includes(searchText) || owner.includes(searchText)) { + seen.add(repo.id); + controller.enqueue( + send( + JSON.stringify({ + id: repo.id, + name: repo.name, + owner: { login: repo.owner.login, avatarUrl: repo.owner.avatar_url }, + stars: repo.stargazers_count || 0, + }), + ), + ); } } } catch { @@ -95,17 +72,12 @@ export default defineEventHandler(async (event) => { } } - sendDone(); + controller.enqueue(send("[DONE]")); } catch (err) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ error: (err as Error).message })}\n\n`, - ), - ); + controller.enqueue(send(JSON.stringify({ error: (err as Error).message }))); + } finally { controller.close(); } }, }); - - return stream; }); From 8511277fc1a5a0accf5a8f38dec9ea42844baba0 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 1 Dec 2025 10:47:06 +0330 Subject: [PATCH 5/6] prettier --- packages/app/app/components/RepoSearch.vue | 50 ++++++++++++++++++---- packages/app/server/api/repo/search.get.ts | 15 +++++-- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index ef21a0ec..2f53f83a 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -53,8 +53,14 @@ watch( if (repo.error) continue; // Insert sorted by stars, keep top 10 - const idx = searchResults.value.findIndex((r) => r.stars < repo.stars); - searchResults.value.splice(idx === -1 ? searchResults.value.length : idx, 0, repo); + const idx = searchResults.value.findIndex( + (r) => r.stars < repo.stars, + ); + searchResults.value.splice( + idx === -1 ? searchResults.value.length : idx, + 0, + repo, + ); if (searchResults.value.length > 10) searchResults.value.pop(); } catch { // Skip malformed JSON @@ -71,18 +77,41 @@ watch( ); const examples = [ - { owner: "vitejs", name: "vite", avatar: "https://avatars.githubusercontent.com/u/65625612?v=4" }, - { owner: "rolldown", name: "rolldown", avatar: "https://avatars.githubusercontent.com/u/94954945?s=200&v=4" }, - { owner: "vuejs", name: "core", avatar: "https://avatars.githubusercontent.com/u/6128107?v=4" }, - { owner: "sveltejs", name: "svelte", avatar: "https://avatars.githubusercontent.com/u/23617963?s=200&v=4" }, - { owner: "Tresjs", name: "tres", avatar: "https://avatars.githubusercontent.com/u/119253150?v=4" }, + { + owner: "vitejs", + name: "vite", + avatar: "https://avatars.githubusercontent.com/u/65625612?v=4", + }, + { + owner: "rolldown", + name: "rolldown", + avatar: "https://avatars.githubusercontent.com/u/94954945?s=200&v=4", + }, + { + owner: "vuejs", + name: "core", + avatar: "https://avatars.githubusercontent.com/u/6128107?v=4", + }, + { + owner: "sveltejs", + name: "svelte", + avatar: "https://avatars.githubusercontent.com/u/23617963?s=200&v=4", + }, + { + owner: "Tresjs", + name: "tres", + avatar: "https://avatars.githubusercontent.com/u/119253150?v=4", + }, ]; const router = useRouter(); function openFirstResult() { const [first] = searchResults.value; if (first) { - router.push({ name: "repo:details", params: { owner: first.owner.login, repo: first.name } }); + router.push({ + name: "repo:details", + params: { owner: first.owner.login, repo: first.name }, + }); } } @@ -113,7 +142,10 @@ function openFirstResult() { />
-
+
No repositories found
diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index 3663038e..02edd0ef 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -53,14 +53,21 @@ export default defineEventHandler(async (event) => { stringSimilarity.compareTwoStrings(owner, searchText), ); - if (score > 0.3 || name.includes(searchText) || owner.includes(searchText)) { + if ( + score > 0.3 || + name.includes(searchText) || + owner.includes(searchText) + ) { seen.add(repo.id); controller.enqueue( send( JSON.stringify({ id: repo.id, name: repo.name, - owner: { login: repo.owner.login, avatarUrl: repo.owner.avatar_url }, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url, + }, stars: repo.stargazers_count || 0, }), ), @@ -74,7 +81,9 @@ export default defineEventHandler(async (event) => { controller.enqueue(send("[DONE]")); } catch (err) { - controller.enqueue(send(JSON.stringify({ error: (err as Error).message }))); + controller.enqueue( + send(JSON.stringify({ error: (err as Error).message })), + ); } finally { controller.close(); } From fed3a03e06f36a475ca2b9da230f9e8d348c7e0f Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 1 Dec 2025 11:58:55 +0330 Subject: [PATCH 6/6] update --- packages/app/server/api/repo/search.get.ts | 39 +++++++++++----------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index 02edd0ef..c9bcc0b5 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -26,12 +26,13 @@ export default defineEventHandler(async (event) => { const app = useOctokitApp(event); const searchText = query.text.toLowerCase(); const seen = new Set(); - const encoder = new TextEncoder(); - const send = (data: string) => encoder.encode(`data: ${data}\n\n`); - - return new ReadableStream({ + const stream = new ReadableStream({ async start(controller) { + const encoder = new TextEncoder(); + const send = (data: string) => + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + try { for await (const { installation } of app.eachInstallation.iterator()) { if (signal.aborted) break; @@ -59,18 +60,16 @@ export default defineEventHandler(async (event) => { owner.includes(searchText) ) { seen.add(repo.id); - controller.enqueue( - send( - JSON.stringify({ - id: repo.id, - name: repo.name, - owner: { - login: repo.owner.login, - avatarUrl: repo.owner.avatar_url, - }, - stars: repo.stargazers_count || 0, - }), - ), + send( + JSON.stringify({ + id: repo.id, + name: repo.name, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url, + }, + stars: repo.stargazers_count || 0, + }), ); } } @@ -79,14 +78,14 @@ export default defineEventHandler(async (event) => { } } - controller.enqueue(send("[DONE]")); + send("[DONE]"); } catch (err) { - controller.enqueue( - send(JSON.stringify({ error: (err as Error).message })), - ); + send(JSON.stringify({ error: (err as Error).message })); } finally { controller.close(); } }, }); + + return stream; });