const CACHE_NAME = "ls-cache-v1";
|
const CACHE_EXPIRATION_HEADER = "x-cache-expiration";
|
const CACHE_EXPIRATION_DEFAULT = 50 * 1000; // 50 seconds
|
const CACHE_CLEANUP_INTERVAL = 60 * 60 * 1000; // 1 hour
|
|
function parseCacheHeaders(cachedResponse) {
|
const expirationTimestamp = parseInt(
|
cachedResponse.headers.get(CACHE_EXPIRATION_HEADER)
|
);
|
|
return {
|
expirationTimestamp: isNaN(expirationTimestamp) ? 0 : expirationTimestamp,
|
};
|
}
|
|
function removeExpiredItems() {
|
caches.open(CACHE_NAME).then((cache) => {
|
cache.keys().then((requests) => {
|
requests.forEach((request) => {
|
cache.match(request).then((response) => {
|
const { expirationTimestamp } = parseCacheHeaders(response);
|
if (expirationTimestamp && Date.now() > expirationTimestamp) {
|
cache.delete(request);
|
}
|
});
|
});
|
});
|
});
|
}
|
|
// Cleanup expired items every hour in case the user never refreshes the page
|
// This is to avoid the cache growing indefinitely, though the data is not large it is just a precaution
|
function startCleanupTimer() {
|
if (!self.cleanupTimer) {
|
removeExpiredItems(); // Perform cleanup immediately
|
self.cleanupTimer = setInterval(removeExpiredItems, CACHE_CLEANUP_INTERVAL);
|
}
|
}
|
|
async function handlePresignedUrl(event) {
|
// For now just catch presign requests and cache them
|
// to void constantly requesting the same presign URL
|
// even when it is not expired.
|
// This is so the server can just naively proxy the presign request
|
// and the performance is not degraded.
|
const requestsPathToCache = /\/presign\/|\/resolve\//;
|
|
// Check if the request URL doesn't match the specified path part
|
// or if it is not a request to the same origin we will just fetch it
|
if (
|
!event.request.url.startsWith(self.location.origin) ||
|
!requestsPathToCache.test(event.request.url) ||
|
// This is to avoid an error trying to load a direct presign URL in a new tab
|
!event.request.referrer ||
|
// Easier to leave this uncached as if we were to handle this caching
|
// it would be more complex and not really worth the effort as it is not the most repeated
|
// request and it is not a big deal if it is not cached.
|
// If this was to be cached it forces a full resource request instead of a chunked ranged one.
|
// This also avoids caching the data directly when resolving uri -> data, which is not the goal of this service worker
|
event.request.headers.get("range")
|
) {
|
// For other requests, just allow the network to handle it
|
return false;
|
}
|
|
event.respondWith(
|
// Cache first approach, look for the cached response and check if it's still valid
|
caches
|
.match(event.request)
|
.then(async (cachedResponse) => {
|
if (cachedResponse) {
|
// Check if the expiration timestamp has passed
|
const { expirationTimestamp } = parseCacheHeaders(cachedResponse);
|
|
const valid =
|
!expirationTimestamp || expirationTimestamp > Date.now();
|
const url = cachedResponse.headers.get("Location");
|
|
if (valid && url) {
|
// If the cached response is a redirect, fetch the redirect URL
|
return fetch(url);
|
}
|
}
|
|
// If there's no valid cached response, fetch redirect from the network and cache the response
|
// fetch the actual request to return to the user immediately
|
return fetch(event.request.url).then((response) => {
|
if (response.redirected) {
|
// Get the Cache-Control header value if provided
|
const cacheControl = response.headers.get("Cache-Control");
|
// This will be the target URL if the response is a redirect
|
const url = response.url;
|
|
// Extract the max-age value from the Cache-Control header
|
// If there's no max-age, use the default value
|
const maxAgeMatch =
|
cacheControl && cacheControl.match(/max-age=(\d+)/);
|
|
let maxAge = CACHE_EXPIRATION_DEFAULT;
|
|
if (maxAgeMatch) {
|
const _age = parseInt(maxAgeMatch[1]);
|
if (!isNaN(_age)) {
|
maxAge = (_age - 10) * 1000; // Leave 10s of margin so there is no possible overlap
|
}
|
}
|
|
// Calculate the expiration timestamp based on the max-age value
|
const expirationTimestamp = Date.now() + maxAge;
|
|
const headers = new Headers(response.headers);
|
headers.append("Location", url);
|
headers.append(CACHE_EXPIRATION_HEADER, expirationTimestamp);
|
|
// Create a redirect response with the expiration timestamp header
|
const cachedResponse = new Response(null, {
|
status: 303,
|
statusText: "See Other",
|
headers: headers,
|
});
|
|
// Store the new Response object in the cache
|
caches.open(CACHE_NAME).then(async (cache) => {
|
try {
|
await cache.put(event.request, cachedResponse);
|
} catch (error) {
|
if (error.name !== "QuotaExceededError") {
|
// Ignore QuotaExceededError errors, they are expected
|
console.error("Cache put failed:", error);
|
}
|
}
|
});
|
}
|
|
return response;
|
});
|
})
|
.catch((error) => {
|
console.error("Fetch failed:", error);
|
throw error;
|
})
|
);
|
|
// response was handled by the serviceworker.
|
// this is for later so we can create a middleware style pipeline to attempt to match and handle
|
// the request in order of priority
|
return true;
|
}
|
|
self.addEventListener("install", (event) => {
|
event.waitUntil(
|
caches.open(CACHE_NAME).then(() => {
|
console.log("Opened cache:", CACHE_NAME);
|
})
|
);
|
});
|
|
self.addEventListener("activate", (event) => {
|
event.waitUntil(clients.claim());
|
});
|
|
self.addEventListener("fetch", async (event) => {
|
await handlePresignedUrl(event);
|
});
|
|
self.addEventListener("message", (event) => {
|
if (event.data && event.data.type === "awaken") {
|
startCleanupTimer();
|
}
|
});
|
|
startCleanupTimer();
|