Skip to content

Commit

Permalink
feat: add caching to service worker (#92)
Browse files Browse the repository at this point in the history
* 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>
  • Loading branch information
3 people authored Mar 14, 2024
1 parent 220e9cb commit 4674dd0
Showing 1 changed file with 110 additions and 8 deletions.
118 changes: 110 additions & 8 deletions src/sw.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ interface GetVerifiedFetchUrlOptions {
path: string
}

interface StoreReponseInCacheOptions {
response: Response
cacheKey: string
isMutable: boolean
}

/**
******************************************************
* "globals"
Expand All @@ -40,6 +46,9 @@ interface GetVerifiedFetchUrlOptions {
declare let self: ServiceWorkerGlobalScope
let verifiedFetch: VerifiedFetch
const channel = new HeliaServiceWorkerCommsChannel('SW')
const IMMUTABLE_CACHE = 'IMMUTABLE_CACHE'
const MUTABLE_CACHE = 'MUTABLE_CACHE'
const ONE_HOUR_IN_SECONDS = 3600
const urlInterceptRegex = [new RegExp(`${self.location.origin}/ip(n|f)s/`)]
const updateVerifiedFetch = async (): Promise<void> => {
verifiedFetch = await getVerifiedFetch()
Expand Down Expand Up @@ -85,6 +94,7 @@ self.addEventListener('fetch', (event) => {
const request = event.request
const urlString = request.url
const url = new URL(urlString)
log('helia-sw: incoming request url: %s:', event.request.url)

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

if (isRootRequestForContent(event)) {
// intercept and do our own stuff...
event.respondWith(fetchHandler({ path: url.pathname, request }))
} else if (isSubdomainRequest(event)) {
event.respondWith(fetchHandler({ path: url.pathname, request }))
event.respondWith(getResponseFromCacheOrFetch(event))
}
})

Expand Down Expand Up @@ -177,13 +186,99 @@ function isSwAssetRequest (event: FetchEvent): boolean {
return isActualSwAsset
}

/**
* Set the expires header on a response object to a timestamp based on the passed ttl interval
* Defaults to
*/
function setExpiresHeader (response: Response, ttlSeconds: number = ONE_HOUR_IN_SECONDS): void {
const expirationTime = new Date(Date.now() + ttlSeconds * 1000)

response.headers.set('sw-cache-expires', expirationTime.toUTCString())
}

/**
* Checks whether a cached response object has expired by looking at the expires header
* Note that this ignores the Cache-Control header since the expires header is set by us
*/
function hasExpired (response: Response): boolean {
const expiresHeader = response.headers.get('sw-cache-expires')

if (expiresHeader == null) {
return false
}

const expires = new Date(expiresHeader)
const now = new Date()

return expires < now
}

function getCacheKey (event: FetchEvent): string {
return `${event.request.url}-${event.request.headers.get('Accept') ?? ''}`
}

async function fetchAndUpdateCache (event: FetchEvent, url: URL, cacheKey: string): Promise<Response> {
const response = await fetchHandler({ path: url.pathname, request: event.request })
try {
await storeReponseInCache({ response, isMutable: true, cacheKey })
trace('helia-ws: updated cache for %s', cacheKey)
} catch (err) {
error('helia-ws: failed updating response in cache for %s', cacheKey, err)
}
return response
}

async function getResponseFromCacheOrFetch (event: FetchEvent): Promise<Response> {
const { protocol } = getSubdomainParts(event.request.url)
const url = new URL(event.request.url)
const isMutable = protocol === 'ipns'
const cacheKey = getCacheKey(event)
trace('helia-sw: cache key: %s', cacheKey)
const cache = await caches.open(isMutable ? MUTABLE_CACHE : IMMUTABLE_CACHE)
const cachedResponse = await cache.match(cacheKey)
const validCacheHit = cachedResponse != null && !hasExpired(cachedResponse)

if (validCacheHit) {
log('helia-ws: cached response HIT for %s (expires: %s) %o', cacheKey, cachedResponse.headers.get('sw-cache-expires'), cachedResponse)

if (isMutable) {
// If the response is mutable, update the cache in the background.
void fetchAndUpdateCache(event, url, cacheKey)
}

return cachedResponse
}

log('helia-ws: cached response MISS for %s', cacheKey)

return fetchAndUpdateCache(event, url, cacheKey)
}

async function storeReponseInCache ({ response, isMutable, cacheKey }: StoreReponseInCacheOptions): Promise<void> {
// 👇 only cache successful responses
if (!response.ok) {
return
}
trace('helia-ws: updating cache for %s in the background', cacheKey)

const cache = await caches.open(isMutable ? MUTABLE_CACHE : IMMUTABLE_CACHE)

// Clone the response since streams can only be consumed once.
const respToCache = response.clone()

if (isMutable) {
trace('helia-ws: setting expires header on response key %s before storing in cache', cacheKey)
// 👇 Set expires header to an hour from now for mutable (ipns://) resources
// Note that this technically breaks HTTP semantics, whereby the cache-control max-age takes precendence
// Setting this header is only used by the service worker using a mechanism similar to stale-while-revalidate
setExpiresHeader(respToCache, ONE_HOUR_IN_SECONDS)
}

log('helia-ws: storing response for key %s in cache', cacheKey)
await cache.put(cacheKey, respToCache)
}

async function fetchHandler ({ path, request }: FetchHandlerArg): Promise<Response> {
/**
* > Any global variables you set will be lost if the service worker shuts down.
*
* @see https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle
*/
verifiedFetch = verifiedFetch ?? await getVerifiedFetch()
// test and enforce origin isolation before anything else is executed
const originLocation = await findOriginIsolationRedirect(new URL(request.url))
if (originLocation !== null) {
Expand All @@ -197,6 +292,13 @@ async function fetchHandler ({ path, request }: FetchHandlerArg): Promise<Respon
})
}

/**
* > Any global variables you set will be lost if the service worker shuts down.
*
* @see https://developer.chrome.com/docs/extensions/develop/concepts/service-workers/lifecycle
*/
verifiedFetch = verifiedFetch ?? await getVerifiedFetch()

/**
* Note that there are existing bugs regarding service worker signal handling:
* * https://bugs.chromium.org/p/chromium/issues/detail?id=823697
Expand Down

0 comments on commit 4674dd0

Please sign in to comment.