148 lines
3.7 KiB
TypeScript

/// <reference lib="WebWorker" />
/// <reference types="vite/client" />
import { DAY } from '../utils/time';
const CACHE_NAME_PREFIX = 'mastodon-';
const CACHE_HEADER_TTL = 'x-timestamp';
export async function cacheRoot() {
const cache = await openWebCache();
const response = await fetch('/', {
credentials: 'include',
redirect: 'manual',
});
await cache.put('/', response);
}
export function handleFetch(event: FetchEvent) {
const url = new URL(event.request.url);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
return;
}
if (url.pathname === '/auth/sign_out') {
event.respondWith(handleLogout(event));
} else if (/intl\/.*\.js$/.test(url.pathname)) {
event.respondWith(cacheFirst({ event, name: 'locales' }));
} else if (event.request.destination === 'font') {
event.respondWith(cacheFirst({ event, name: 'fonts' }));
} else if (event.request.destination === 'image') {
event.respondWith(cacheFirst({ event, name: 'images', ttl: DAY * 7 }));
}
}
async function cacheFirst({
event,
name,
ttl = DAY * 30,
max = 5,
}: {
event: FetchEvent;
name: string;
ttl?: number;
max?: number;
}) {
const cache = await caches.open(`${CACHE_NAME_PREFIX}${name}`);
const request = event.request;
const cachedResponse = await cache.match(request);
// Start expiring cache items while the process continues.
void expireCachedItems({ name, ttl, max });
if (cachedResponse) {
// If we have a cached response, check the TTL header.
const ttlHeader = Number.parseInt(
cachedResponse.headers.get(CACHE_HEADER_TTL) ?? '0',
);
if (!ttlHeader || ttlHeader + ttl > Date.now()) {
return cachedResponse;
}
}
const networkResponse = await fetch(request);
// For opaque responses, the status will be zero so we can't clone them.
if (networkResponse.status !== 0) {
// Clone request with a custom header to store timestamp.
const cloneHeaders = new Headers(networkResponse.headers);
cloneHeaders.set(CACHE_HEADER_TTL, Date.now().toString());
const cloneResponse = new Response(networkResponse.clone().body, {
headers: cloneHeaders,
status: networkResponse.status,
statusText: networkResponse.statusText,
});
await cache.put(request, cloneResponse);
}
return networkResponse;
}
export async function expireCachedItems({
name,
ttl = DAY * 30,
max = 5,
}: {
name: string;
ttl?: number;
max?: number;
}) {
const cache = await caches.open(`${CACHE_NAME_PREFIX}${name}`);
const keys = await cache.keys();
const now = Date.now();
const validKeys: { key: Request; timestamp: number }[] = [];
for (const key of keys) {
const cachedResponse = await cache.match(key);
if (!cachedResponse) {
await cache.delete(key);
continue;
}
const timestamp = Number.parseInt(
cachedResponse.headers.get(CACHE_HEADER_TTL) ?? '0',
);
if (!timestamp || timestamp + ttl > now) {
validKeys.push({ key, timestamp: timestamp || Number.POSITIVE_INFINITY });
continue;
}
await cache.delete(key);
}
if (validKeys.length <= max) {
return;
}
const sortedValidKeys = validKeys.toSorted(
({ timestamp: a }, { timestamp: b }) => a - b,
);
await Promise.all(
sortedValidKeys
.slice(0, sortedValidKeys.length - max)
.map(({ key }) => cache.delete(key)),
);
}
function openWebCache() {
return caches.open(`${CACHE_NAME_PREFIX}web`);
}
async function handleLogout(event: FetchEvent) {
const response = await fetch(event.request);
if (response.ok || response.type === 'opaqueredirect') {
const cache = await openWebCache();
await cache.delete('/');
}
return response;
}