Skip to content

Commit 4d8aecf

Browse files
committed
feat: use cache api to cache responses
1 parent 023f834 commit 4d8aecf

File tree

4 files changed

+113
-9
lines changed

4 files changed

+113
-9
lines changed

packages/client/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,7 +153,8 @@
153153
"@libp2p/crypto": "^5.0.1",
154154
"aegir": "^45.0.1",
155155
"body-parser": "^1.20.3",
156-
"it-all": "^3.0.6"
156+
"it-all": "^3.0.6",
157+
"wherearewe": "^2.0.1"
157158
},
158159
"sideEffects": false
159160
}

packages/client/src/client.ts

Lines changed: 59 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ const log = logger('delegated-routing-v1-http-api-client')
2020

2121
const 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

2627
export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV1HttpApiClient {
@@ -34,7 +35,8 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
3435
private readonly filterAddrs?: string[]
3536
private readonly filterProtocols?: string[]
3637
private readonly inFlightRequests: Map<string, Promise<Response>>
37-
38+
private cache?: Cache
39+
private readonly cacheTTL: number
3840
/**
3941
* Create a new DelegatedContentRouting instance
4042
*/
@@ -52,6 +54,17 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
5254
this.filterProtocols = init.filterProtocols
5355
this.contentRouting = new DelegatedRoutingV1HttpApiClientContentRouting(this)
5456
this.peerRouting = new DelegatedRoutingV1HttpApiClientPeerRouting(this)
57+
58+
const cacheEnabled = typeof globalThis.caches !== 'undefined'
59+
if (cacheEnabled) {
60+
log('cache enabled')
61+
globalThis.caches.open('delegated-routing-v1-cache').then(cache => {
62+
this.cache = cache
63+
}).catch(() => {
64+
this.cache = undefined
65+
})
66+
}
67+
this.cacheTTL = init.cacheTTL ?? defaultValues.cacheTTL
5568
}
5669

5770
get [contentRoutingSymbol] (): ContentRouting {
@@ -74,6 +87,11 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
7487
this.httpQueue.clear()
7588
this.shutDownController.abort()
7689
this.started = false
90+
91+
// Clear the cache when stopping
92+
if (this.cache != null) {
93+
void this.cache.delete('delegated-routing-v1-cache')
94+
}
7795
}
7896

7997
async * getProviders (cid: CID, options: GetProvidersOptions = {}): AsyncGenerator<PeerRecord> {
@@ -352,27 +370,60 @@ export class DefaultDelegatedRoutingV1HttpApiClient implements DelegatedRoutingV
352370
}
353371
}
354372

355-
// Ensures that only one concurrent request is made for the same url-method tuple
373+
//
374+
// and caches GET requests
375+
/**
376+
* makeRequest has two features:
377+
* - Ensures only one concurrent request is made for the same URL
378+
* - Caches GET requests if the Cache API is available
379+
*/
356380
async #makeRequest (url: string, options: RequestInit): Promise<Response> {
357-
const key = `${options.method ?? 'GET'}-${url}`
381+
const requestMethod = options.method ?? 'GET'
382+
const key = `${requestMethod}-${url}`
383+
384+
// Only try to use cache for GET requests
385+
if (requestMethod === 'GET') {
386+
const cachedResponse = await this.cache?.match(url)
387+
if (cachedResponse != null) {
388+
// Check if the cached response has expired
389+
const expires = parseInt(cachedResponse.headers.get('x-cache-expires') ?? '0', 10)
390+
if (expires > Date.now()) {
391+
log('returning cached response for %s', key)
392+
return cachedResponse
393+
} else {
394+
// Remove expired response from cache
395+
await this.cache?.delete(url)
396+
}
397+
}
398+
}
358399

359-
// Check if there's already an in-flight request for this ur-method tuple
400+
// Check if there's already an in-flight request for this URL
360401
const existingRequest = this.inFlightRequests.get(key)
361402
if (existingRequest != null) {
362403
const response = await existingRequest
363-
// Clone the response since it can only be consumed once
404+
log('deduplicating outgoing request for %s', key)
364405
return response.clone()
365406
}
366407

367408
// Create new request and track it
368-
const requestPromise = fetch(url, options).finally(() => {
409+
const requestPromise = fetch(url, options).then(async response => {
410+
// Only cache successful GET requests
411+
if (this.cache != null && response.ok && requestMethod === 'GET') {
412+
// Create a new response with expiration header
413+
const cachedResponse = response.clone()
414+
const expires = Date.now() + this.cacheTTL
415+
cachedResponse.headers.set('x-cache-expires', expires.toString())
416+
417+
await this.cache.put(url, cachedResponse)
418+
}
419+
return response
420+
}).finally(() => {
369421
// Clean up the tracked request when it completes
370422
this.inFlightRequests.delete(key)
371423
})
372424

373425
this.inFlightRequests.set(key, requestPromise)
374426
const response = await requestPromise
375-
// Return a clone for the first caller too, so all callers get a fresh response
376427
return response.clone()
377428
}
378429
}

packages/client/src/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,11 @@ export interface DelegatedRoutingV1HttpApiClientInit extends FilterOptions {
124124
* How long a request is allowed to take in ms (default: 30 seconds)
125125
*/
126126
timeout?: number
127+
128+
/**
129+
* How long to cache responses for in ms (default: 5 minutes)
130+
*/
131+
cacheTTL?: number
127132
}
128133

129134
export interface GetIPNSOptions extends AbortOptions {

packages/client/test/index.spec.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ import { expect } from 'aegir/chai'
77
import { createIPNSRecord, marshalIPNSRecord } from 'ipns'
88
import all from 'it-all'
99
import { CID } from 'multiformats/cid'
10+
import { isBrowser } from 'wherearewe'
1011
import { createDelegatedRoutingV1HttpApiClient, type DelegatedRoutingV1HttpApiClient } from '../src/index.js'
1112

1213
if (process.env.ECHO_SERVER == null) {
1314
throw new Error('Echo server not configured correctly')
1415
}
1516

1617
const serverUrl = process.env.ECHO_SERVER
18+
const itBrowser = (isBrowser ? it : it.skip)
1719

1820
describe('delegated-routing-v1-http-api-client', () => {
1921
let client: DelegatedRoutingV1HttpApiClient
@@ -349,4 +351,49 @@ describe('delegated-routing-v1-http-api-client', () => {
349351
})))
350352
})
351353
})
354+
355+
itBrowser('should respect cache TTL', async () => {
356+
const shortTTL = 100 // 100ms TTL for testing
357+
const clientWithShortTTL = createDelegatedRoutingV1HttpApiClient(new URL(serverUrl), {
358+
cacheTTL: shortTTL
359+
})
360+
361+
const cid = CID.parse('QmUNLLsPACCz1vLxQVkXqqLX5R1X345qqfHbsf67hvA3Nn')
362+
const providers = [{
363+
Protocol: 'transport-bitswap',
364+
Schema: 'bitswap',
365+
Metadata: 'gBI=',
366+
ID: (await generateKeyPair('Ed25519')).publicKey.toString(),
367+
Addrs: ['/ip4/41.41.41.41/tcp/1234']
368+
}]
369+
370+
// load providers for the router to fetch
371+
await fetch(`${process.env.ECHO_SERVER}/add-providers/${cid.toString()}`, {
372+
method: 'POST',
373+
body: providers.map(prov => JSON.stringify(prov)).join('\n')
374+
})
375+
376+
// Reset call count
377+
await fetch(`${process.env.ECHO_SERVER}/reset-call-count`)
378+
379+
// First request should hit the server
380+
await all(clientWithShortTTL.getProviders(cid))
381+
382+
// Second request should use cache
383+
await all(clientWithShortTTL.getProviders(cid))
384+
385+
let callCount = parseInt(await (await fetch(`${process.env.ECHO_SERVER}/get-call-count`)).text(), 10)
386+
expect(callCount).to.equal(1) // Only one server call so far
387+
388+
// Wait for cache to expire
389+
await new Promise(resolve => setTimeout(resolve, shortTTL + 50))
390+
391+
// This request should hit the server again because cache expired
392+
await all(clientWithShortTTL.getProviders(cid))
393+
394+
callCount = parseInt(await (await fetch(`${process.env.ECHO_SERVER}/get-call-count`)).text(), 10)
395+
expect(callCount).to.equal(2) // Second server call after cache expired
396+
397+
clientWithShortTTL.stop()
398+
})
352399
})

0 commit comments

Comments
 (0)