Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add caching to service worker #92

Merged
merged 20 commits into from
Mar 14, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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))
2color marked this conversation as resolved.
Show resolved Hide resolved
}
})

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
2color marked this conversation as resolved.
Show resolved Hide resolved
// 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()

2color marked this conversation as resolved.
Show resolved Hide resolved
/**
* Note that there are existing bugs regarding service worker signal handling:
* * https://bugs.chromium.org/p/chromium/issues/detail?id=823697
Expand Down
Loading