From 5669489dc9284af55d8fd7bdd1726b442d12fac1 Mon Sep 17 00:00:00 2001 From: Julien Oculi Date: Wed, 17 Jul 2024 03:21:11 +0200 Subject: [PATCH] feat(pwa): :sparkles: basic offline mode and sw fetch strategies --- islands/StartServiceWorker.tsx | 2 +- src/serviceworker/mod.ts | 143 +++++++++++++++++++++++++++++---- 2 files changed, 130 insertions(+), 15 deletions(-) diff --git a/islands/StartServiceWorker.tsx b/islands/StartServiceWorker.tsx index 764f88f..ffcf7c2 100644 --- a/islands/StartServiceWorker.tsx +++ b/islands/StartServiceWorker.tsx @@ -4,7 +4,7 @@ const IS_SW = 'onpushsubscriptionchange' in self export default function StartServiceWorker() { if (IS_SW) { - main(location.origin) + main() } return new URL(import.meta.url).pathname } diff --git a/src/serviceworker/mod.ts b/src/serviceworker/mod.ts index 307b726..6929001 100644 --- a/src/serviceworker/mod.ts +++ b/src/serviceworker/mod.ts @@ -1,30 +1,71 @@ /// /// +import { ApiPayload } from ':src/utils.ts' import type { PrecacheResponse } from '../../routes/api/serviceworker/precache.tsx' +import type { JsonValue } from '$std/json/common.ts' // Force load service worker types const self = globalThis as unknown as ServiceWorkerGlobalScope -export async function main(origin: string) { - const cacheConfig = await fetch( - new URL('/api/serviceworker/precache', origin), - ).then((response) => response.json() as Promise) - const cacheName = cacheConfig.version +// Pseudo storage API for SW +const swStorage = { + async getItem(key: string): Promise { + const cache = await caches.open('$SW_STORAGE') + const request = new Request(`http://null/${encodeURIComponent(key)}`) + const response = await cache.match(request) + if (response !== undefined) { + return response.json() + } + + return null + }, + async setItem(key: string, item: T): Promise { + const cache = await caches.open('$SW_STORAGE') + const request = new Request(`http://null/${encodeURIComponent(key)}`) + const response = Response.json(item) + + return cache.put(request, response) + }, +} + +async function getPreCachedUrls(): Promise { + const raw = await swStorage.getItem('$sw.cache.urls') + if (raw === null) { + await openCache() + return getPreCachedUrls() + } + return raw +} + +async function getCache(): Promise { + const version = await swStorage.getItem('$sw.cache.version') + if (version === null) { + await openCache() + return getCache() + } + return caches.open(version) +} + +const IS_SW = 'onpushsubscriptionchange' in self +if (IS_SW) { self.addEventListener('install', (event) => { - event.waitUntil( - //precache static files and routes index - addToCache(cacheName, cacheConfig.files) - ) + // Assign global cache and pre-cached-urls + event.waitUntil(openCache()) }) self.addEventListener('activate', () => { //TODO handle activation }) - self.addEventListener('fetch', (_event) => { - //TODO add fetch strategies + self.addEventListener('fetch', (event) => { + const url = new URL(event.request.url) + + // Don't handle 3rd party request + if (url.origin !== location.origin) return + + event.respondWith(fetchHandler(event)) }) self.addEventListener('push', (event) => { @@ -40,7 +81,81 @@ export async function main(origin: string) { }) } -async function addToCache(cacheName: string, ressources: string[]) { - const cache = await caches.open(cacheName) - await cache.addAll(ressources) +async function fetchHandler(event: FetchEvent) { + const url = new URL(event.request.url) + const method = event.request.method + const preCachedUrls = await getPreCachedUrls() + const cache = await getCache() + + // Cache first for "pre-cached-urls" + if (preCachedUrls.includes(url.pathname) && method === 'GET') { + const cached = await cache.match(event.request) ?? + await fetch(event.request).catch(() => null) ?? + await cache.match(event.request, { ignoreSearch: true }) + + if (cached === undefined) { + throw new Error(`no cache available for pre-cached-url "${url}"`) + } + return cached + } + + // Cache first and refresh + if (url.origin === location.origin && method === 'GET') { + const cached = await cache.match(event.request) + + const response = fetch(event.request) + .then((response) => { + // Update cache + cache.put(event.request, response.clone()) + return response + }) + .catch((cause) => { + // Try serve cache if no network + if (cached === undefined) { + throw new Error(`no cache available for "${url}"`, { cause }) + } + return cached + }) + + if (cached === undefined) { + return response + } + + return cached + } + + // Network only + return fetch(event.request) +} + +async function openCache() { + const response = await fetch('/api/serviceworker/precache').then( + (response) => response.json() as Promise>, + ) + + if (response.kind === 'error') { + throw new Error('unable to get pre-cached resources from server', { + cause: new Error(response.error), + }) + } + + const { version, preCachedUrls } = response.data + const cache = await caches.open(version) + + // Pre-cache static files and routes index + const addList: Promise[] = [] + // Prevent bunch error by splitting cache.addAll + for (const url of preCachedUrls) { + addList.push(cache.add(url)) + } + await Promise.allSettled(addList) + + await swStorage.setItem('$sw.cache.version', version) + await swStorage.setItem('$sw.cache.urls', preCachedUrls) + + return { cache, version, preCachedUrls } +} + +export function main() { + console.assert() }