1+ import type { H3Event } from "h3" ;
12import stringSimilarity from "string-similarity" ;
23import { z } from "zod" ;
34import { 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