Skip to content

Commit 4674dd0

Browse files
2colorSgtPooki
andauthored
feat: add caching to service worker (#92)
* feat: naive cache implmentation * fix: only cache successful responses * feat: improve cache mechanism * fix: linting errors * refactor: move caching logic to separate functions also make sure that we actually store reponse in cache when fetching it in the background * fix: small optimisation * chore: change log to trace * chore: PR comments and minor improvements * fix: use sw-cache-expires * chore: cleanup hasExpired return * chore: log on cache miss * chore: wait for response so we can cache it * chore: minor cleanup * chore: minor cleanup * fix: stale-while-revalidate for ipns * chore: move magic number to constant --------- Co-authored-by: Daniel N <2color@users.noreply.github.com> Co-authored-by: Russell Dempsey <1173416+SgtPooki@users.noreply.github.com>
1 parent 220e9cb commit 4674dd0

File tree

1 file changed

+110
-8
lines changed

1 file changed

+110
-8
lines changed

src/sw.ts

+110-8
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,12 @@ interface GetVerifiedFetchUrlOptions {
3232
path: string
3333
}
3434

35+
interface StoreReponseInCacheOptions {
36+
response: Response
37+
cacheKey: string
38+
isMutable: boolean
39+
}
40+
3541
/**
3642
******************************************************
3743
* "globals"
@@ -40,6 +46,9 @@ interface GetVerifiedFetchUrlOptions {
4046
declare let self: ServiceWorkerGlobalScope
4147
let verifiedFetch: VerifiedFetch
4248
const channel = new HeliaServiceWorkerCommsChannel('SW')
49+
const IMMUTABLE_CACHE = 'IMMUTABLE_CACHE'
50+
const MUTABLE_CACHE = 'MUTABLE_CACHE'
51+
const ONE_HOUR_IN_SECONDS = 3600
4352
const urlInterceptRegex = [new RegExp(`${self.location.origin}/ip(n|f)s/`)]
4453
const updateVerifiedFetch = async (): Promise<void> => {
4554
verifiedFetch = await getVerifiedFetch()
@@ -85,6 +94,7 @@ self.addEventListener('fetch', (event) => {
8594
const request = event.request
8695
const urlString = request.url
8796
const url = new URL(urlString)
97+
log('helia-sw: incoming request url: %s:', event.request.url)
8898

8999
if (isConfigPageRequest(url) || isSwAssetRequest(event)) {
90100
// get the assets from the server
@@ -98,10 +108,9 @@ self.addEventListener('fetch', (event) => {
98108
}
99109

100110
if (isRootRequestForContent(event)) {
101-
// intercept and do our own stuff...
102111
event.respondWith(fetchHandler({ path: url.pathname, request }))
103112
} else if (isSubdomainRequest(event)) {
104-
event.respondWith(fetchHandler({ path: url.pathname, request }))
113+
event.respondWith(getResponseFromCacheOrFetch(event))
105114
}
106115
})
107116

@@ -177,13 +186,99 @@ function isSwAssetRequest (event: FetchEvent): boolean {
177186
return isActualSwAsset
178187
}
179188

189+
/**
190+
* Set the expires header on a response object to a timestamp based on the passed ttl interval
191+
* Defaults to
192+
*/
193+
function setExpiresHeader (response: Response, ttlSeconds: number = ONE_HOUR_IN_SECONDS): void {
194+
const expirationTime = new Date(Date.now() + ttlSeconds * 1000)
195+
196+
response.headers.set('sw-cache-expires', expirationTime.toUTCString())
197+
}
198+
199+
/**
200+
* Checks whether a cached response object has expired by looking at the expires header
201+
* Note that this ignores the Cache-Control header since the expires header is set by us
202+
*/
203+
function hasExpired (response: Response): boolean {
204+
const expiresHeader = response.headers.get('sw-cache-expires')
205+
206+
if (expiresHeader == null) {
207+
return false
208+
}
209+
210+
const expires = new Date(expiresHeader)
211+
const now = new Date()
212+
213+
return expires < now
214+
}
215+
216+
function getCacheKey (event: FetchEvent): string {
217+
return `${event.request.url}-${event.request.headers.get('Accept') ?? ''}`
218+
}
219+
220+
async function fetchAndUpdateCache (event: FetchEvent, url: URL, cacheKey: string): Promise<Response> {
221+
const response = await fetchHandler({ path: url.pathname, request: event.request })
222+
try {
223+
await storeReponseInCache({ response, isMutable: true, cacheKey })
224+
trace('helia-ws: updated cache for %s', cacheKey)
225+
} catch (err) {
226+
error('helia-ws: failed updating response in cache for %s', cacheKey, err)
227+
}
228+
return response
229+
}
230+
231+
async function getResponseFromCacheOrFetch (event: FetchEvent): Promise<Response> {
232+
const { protocol } = getSubdomainParts(event.request.url)
233+
const url = new URL(event.request.url)
234+
const isMutable = protocol === 'ipns'
235+
const cacheKey = getCacheKey(event)
236+
trace('helia-sw: cache key: %s', cacheKey)
237+
const cache = await caches.open(isMutable ? MUTABLE_CACHE : IMMUTABLE_CACHE)
238+
const cachedResponse = await cache.match(cacheKey)
239+
const validCacheHit = cachedResponse != null && !hasExpired(cachedResponse)
240+
241+
if (validCacheHit) {
242+
log('helia-ws: cached response HIT for %s (expires: %s) %o', cacheKey, cachedResponse.headers.get('sw-cache-expires'), cachedResponse)
243+
244+
if (isMutable) {
245+
// If the response is mutable, update the cache in the background.
246+
void fetchAndUpdateCache(event, url, cacheKey)
247+
}
248+
249+
return cachedResponse
250+
}
251+
252+
log('helia-ws: cached response MISS for %s', cacheKey)
253+
254+
return fetchAndUpdateCache(event, url, cacheKey)
255+
}
256+
257+
async function storeReponseInCache ({ response, isMutable, cacheKey }: StoreReponseInCacheOptions): Promise<void> {
258+
// 👇 only cache successful responses
259+
if (!response.ok) {
260+
return
261+
}
262+
trace('helia-ws: updating cache for %s in the background', cacheKey)
263+
264+
const cache = await caches.open(isMutable ? MUTABLE_CACHE : IMMUTABLE_CACHE)
265+
266+
// Clone the response since streams can only be consumed once.
267+
const respToCache = response.clone()
268+
269+
if (isMutable) {
270+
trace('helia-ws: setting expires header on response key %s before storing in cache', cacheKey)
271+
// 👇 Set expires header to an hour from now for mutable (ipns://) resources
272+
// Note that this technically breaks HTTP semantics, whereby the cache-control max-age takes precendence
273+
// Setting this header is only used by the service worker using a mechanism similar to stale-while-revalidate
274+
setExpiresHeader(respToCache, ONE_HOUR_IN_SECONDS)
275+
}
276+
277+
log('helia-ws: storing response for key %s in cache', cacheKey)
278+
await cache.put(cacheKey, respToCache)
279+
}
280+
180281
async function fetchHandler ({ path, request }: FetchHandlerArg): Promise<Response> {
181-
/**
182-
* > Any global variables you set will be lost if the service worker shuts down.
183-
*
184-
* @see https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle
185-
*/
186-
verifiedFetch = verifiedFetch ?? await getVerifiedFetch()
187282
// test and enforce origin isolation before anything else is executed
188283
const originLocation = await findOriginIsolationRedirect(new URL(request.url))
189284
if (originLocation !== null) {
@@ -197,6 +292,13 @@ async function fetchHandler ({ path, request }: FetchHandlerArg): Promise<Respon
197292
})
198293
}
199294

295+
/**
296+
* > Any global variables you set will be lost if the service worker shuts down.
297+
*
298+
* @see https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle
299+
*/
300+
verifiedFetch = verifiedFetch ?? await getVerifiedFetch()
301+
200302
/**
201303
* Note that there are existing bugs regarding service worker signal handling:
202304
* * https://bugs.chromium.org/p/chromium/issues/detail?id=823697

0 commit comments

Comments
 (0)