@@ -20,7 +20,8 @@ const log = logger('delegated-routing-v1-http-api-client')
2020
2121const defaultValues = {
2222 concurrentRequests : 4 ,
23- timeout : 30e3
23+ timeout : 30e3 ,
24+ cacheTTL : 5 * 60 * 1000 // 5 minutes default as per https://specs.ipfs.tech/routing/http-routing-v1/#response-headers
2425}
2526
2627export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpApiClient {
@@ -33,7 +34,9 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
3334 private readonly peerRouting : PeerRouting
3435 private readonly filterAddrs ?: string [ ]
3536 private readonly filterProtocols ?: string [ ]
36-
37+ private readonly inFlightRequests : Map < string , Promise < Response > >
38+ private cache ?: Cache
39+ private readonly cacheTTL : number
3740 /**
3841 * Create a new DelegatedContentRouting instance
3942 */
@@ -44,12 +47,25 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
4447 this . httpQueue = new PQueue ( {
4548 concurrency : init . concurrentRequests ?? defaultValues . concurrentRequests
4649 } )
50+ this . inFlightRequests = new Map ( ) // Tracks in-flight requests to avoid duplicate requests
4751 this . clientUrl = url instanceof URL ? url : new URL ( url )
4852 this . timeout = init . timeout ?? defaultValues . timeout
4953 this . filterAddrs = init . filterAddrs
5054 this . filterProtocols = init . filterProtocols
5155 this . contentRouting = new DelegatedRoutingV1HttpApiClientContentRouting ( this )
5256 this . peerRouting = new DelegatedRoutingV1HttpApiClientPeerRouting ( this )
57+
58+ this . cacheTTL = init . cacheTTL ?? defaultValues . cacheTTL
59+ const cacheEnabled = ( typeof globalThis . caches !== 'undefined' ) && ( this . cacheTTL > 0 )
60+
61+ if ( cacheEnabled ) {
62+ log ( 'cache enabled with ttl %d' , this . cacheTTL )
63+ globalThis . caches . open ( 'delegated-routing-v1-cache' ) . then ( cache => {
64+ this . cache = cache
65+ } ) . catch ( ( ) => {
66+ this . cache = undefined
67+ } )
68+ }
5369 }
5470
5571 get [ contentRoutingSymbol ] ( ) : ContentRouting {
@@ -72,6 +88,11 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
7288 this . httpQueue . clear ( )
7389 this . shutDownController . abort ( )
7490 this . started = false
91+
92+ // Clear the cache when stopping
93+ if ( this . cache != null ) {
94+ void this . cache . delete ( 'delegated-routing-v1-cache' )
95+ }
7596 }
7697
7798 async * getProviders ( cid : CID , options : GetProvidersOptions = { } ) : AsyncGenerator < PeerRecord > {
@@ -95,7 +116,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
95116 const url = new URL ( `${ this . clientUrl } routing/v1/providers/${ cid . toString ( ) } ` )
96117 this . #addFilterParams( url , options . filterAddrs , options . filterProtocols )
97118 const getOptions = { headers : { Accept : 'application/x-ndjson' } , signal }
98- const res = await fetch ( url , getOptions )
119+ const res = await this . #makeRequest ( url . toString ( ) , getOptions )
99120
100121 if ( res . status === 404 ) {
101122 // https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
@@ -162,7 +183,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
162183 this . #addFilterParams( url , options . filterAddrs , options . filterProtocols )
163184
164185 const getOptions = { headers : { Accept : 'application/x-ndjson' } , signal }
165- const res = await fetch ( url , getOptions )
186+ const res = await this . #makeRequest ( url . toString ( ) , getOptions )
166187
167188 if ( res . status === 404 ) {
168189 // https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
@@ -228,7 +249,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
228249 await onStart . promise
229250
230251 const getOptions = { headers : { Accept : 'application/vnd.ipfs.ipns-record' } , signal }
231- const res = await fetch ( resource , getOptions )
252+ const res = await this . #makeRequest ( resource , getOptions )
232253
233254 log ( 'getIPNS GET %s %d' , resource , res . status )
234255
@@ -290,7 +311,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
290311 const body = marshalIPNSRecord ( record )
291312
292313 const getOptions = { method : 'PUT' , headers : { 'Content-Type' : 'application/vnd.ipfs.ipns-record' } , body, signal }
293- const res = await fetch ( resource , getOptions )
314+ const res = await this . #makeRequest ( resource , getOptions )
294315
295316 log ( 'putIPNS PUT %s %d' , resource , res . status )
296317
@@ -349,4 +370,65 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
349370 }
350371 }
351372 }
373+
374+ /**
375+ * makeRequest has two features:
376+ * - Ensures only one concurrent request is made for the same URL
377+ * - Caches GET requests if the Cache API is available
378+ */
379+ async #makeRequest ( url : string , options : RequestInit ) : Promise < Response > {
380+ const requestMethod = options . method ?? 'GET'
381+ const key = `${ requestMethod } -${ url } `
382+
383+ // Only try to use cache for GET requests
384+ if ( requestMethod === 'GET' ) {
385+ const cachedResponse = await this . cache ?. match ( url )
386+ if ( cachedResponse != null ) {
387+ // Check if the cached response has expired
388+ const expires = parseInt ( cachedResponse . headers . get ( 'x-cache-expires' ) ?? '0' , 10 )
389+ if ( expires > Date . now ( ) ) {
390+ log ( 'returning cached response for %s' , key )
391+ return cachedResponse
392+ } else {
393+ // Remove expired response from cache
394+ await this . cache ?. delete ( url )
395+ }
396+ }
397+ }
398+
399+ // Check if there's already an in-flight request for this URL
400+ const existingRequest = this . inFlightRequests . get ( key )
401+ if ( existingRequest != null ) {
402+ const response = await existingRequest
403+ log ( 'deduplicating outgoing request for %s' , key )
404+ return response . clone ( )
405+ }
406+
407+ // Create new request and track it
408+ const requestPromise = fetch ( url , options ) . then ( async response => {
409+ // Only cache successful GET requests
410+ if ( this . cache != null && response . ok && requestMethod === 'GET' ) {
411+ const expires = Date . now ( ) + this . cacheTTL
412+ const headers = new Headers ( response . headers )
413+ headers . set ( 'x-cache-expires' , expires . toString ( ) )
414+
415+ // Create a new response with expiration header
416+ const cachedResponse = new Response ( response . clone ( ) . body , {
417+ status : response . status ,
418+ statusText : response . statusText ,
419+ headers
420+ } )
421+
422+ await this . cache . put ( url , cachedResponse )
423+ }
424+ return response
425+ } ) . finally ( ( ) => {
426+ // Clean up the tracked request when it completes
427+ this . inFlightRequests . delete ( key )
428+ } )
429+
430+ this . inFlightRequests . set ( key , requestPromise )
431+ const response = await requestPromise
432+ return response
433+ }
352434}
0 commit comments