@@ -20,7 +20,8 @@ const log = logger('delegated-routing-v1-http-api-client')
20
20
21
21
const defaultValues = {
22
22
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
24
25
}
25
26
26
27
export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpApiClient {
@@ -33,7 +34,9 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
33
34
private readonly peerRouting : PeerRouting
34
35
private readonly filterAddrs ?: string [ ]
35
36
private readonly filterProtocols ?: string [ ]
36
-
37
+ private readonly inFlightRequests : Map < string , Promise < Response > >
38
+ private cache ?: Cache
39
+ private readonly cacheTTL : number
37
40
/**
38
41
* Create a new DelegatedContentRouting instance
39
42
*/
@@ -44,12 +47,25 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
44
47
this . httpQueue = new PQueue ( {
45
48
concurrency : init . concurrentRequests ?? defaultValues . concurrentRequests
46
49
} )
50
+ this . inFlightRequests = new Map ( ) // Tracks in-flight requests to avoid duplicate requests
47
51
this . clientUrl = url instanceof URL ? url : new URL ( url )
48
52
this . timeout = init . timeout ?? defaultValues . timeout
49
53
this . filterAddrs = init . filterAddrs
50
54
this . filterProtocols = init . filterProtocols
51
55
this . contentRouting = new DelegatedRoutingV1HttpApiClientContentRouting ( this )
52
56
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
+ }
53
69
}
54
70
55
71
get [ contentRoutingSymbol ] ( ) : ContentRouting {
@@ -72,6 +88,11 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
72
88
this . httpQueue . clear ( )
73
89
this . shutDownController . abort ( )
74
90
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
+ }
75
96
}
76
97
77
98
async * getProviders ( cid : CID , options : GetProvidersOptions = { } ) : AsyncGenerator < PeerRecord > {
@@ -95,7 +116,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
95
116
const url = new URL ( `${ this . clientUrl } routing/v1/providers/${ cid . toString ( ) } ` )
96
117
this . #addFilterParams( url , options . filterAddrs , options . filterProtocols )
97
118
const getOptions = { headers : { Accept : 'application/x-ndjson' } , signal }
98
- const res = await fetch ( url , getOptions )
119
+ const res = await this . #makeRequest ( url . toString ( ) , getOptions )
99
120
100
121
if ( res . status === 404 ) {
101
122
// https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
@@ -162,7 +183,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
162
183
this . #addFilterParams( url , options . filterAddrs , options . filterProtocols )
163
184
164
185
const getOptions = { headers : { Accept : 'application/x-ndjson' } , signal }
165
- const res = await fetch ( url , getOptions )
186
+ const res = await this . #makeRequest ( url . toString ( ) , getOptions )
166
187
167
188
if ( res . status === 404 ) {
168
189
// https://specs.ipfs.tech/routing/http-routing-v1/#response-status-codes
@@ -228,7 +249,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
228
249
await onStart . promise
229
250
230
251
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 )
232
253
233
254
log ( 'getIPNS GET %s %d' , resource , res . status )
234
255
@@ -290,7 +311,7 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
290
311
const body = marshalIPNSRecord ( record )
291
312
292
313
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 )
294
315
295
316
log ( 'putIPNS PUT %s %d' , resource , res . status )
296
317
@@ -349,4 +370,65 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
349
370
}
350
371
}
351
372
}
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
+ }
352
434
}
0 commit comments