From dcb2bae9c1385ca9ef507e3b368862eaab53d3db Mon Sep 17 00:00:00 2001 From: FluffyFire <62662486+sturla1511@users.noreply.github.com> Date: Fri, 9 Oct 2026 15:28:54 +0200 Subject: [PATCH] fixed --- .../src/lib/service-worker.test.ts | 86 +++++++++++++++++++ programmerbar-web/src/service-worker.ts | 49 +++-------- 2 files changed, 97 insertions(+), 38 deletions(-) create mode 100644 programmerbar-web/src/lib/service-worker.test.ts diff --git a/programmerbar-web/src/lib/service-worker.test.ts b/programmerbar-web/src/lib/service-worker.test.ts new file mode 100644 index 00000000..fec5a780 --- /dev/null +++ b/programmerbar-web/src/lib/service-worker.test.ts @@ -0,0 +1,86 @@ +import { readFileSync } from 'node:fs'; +import { runInNewContext } from 'node:vm'; +import ts from 'typescript'; +import { describe, expect, it, vi } from 'vitest'; + +const worker = ts.transpileModule( + readFileSync(new URL('../service-worker.ts', import.meta.url), 'utf8'), + { compilerOptions: { module: ts.ModuleKind.CommonJS, target: ts.ScriptTarget.ESNext } } +).outputText; + +function setup() { + const listeners = new Map< + string, + (event: { request: Request; respondWith: ReturnType }) => void + >(); + const match = vi.fn().mockResolvedValue(undefined); + const open = vi.fn().mockResolvedValue({ match }); + const fetch = vi.fn().mockResolvedValue(new Response('network')); + runInNewContext(worker, { + exports: {}, + require: () => ({ + build: ['/_app/immutable/app.js'], + files: ['/favicon.ico'], + version: 'test' + }), + self: { + location: { origin: 'https://programmer.bar' }, + addEventListener: ( + name: string, + listener: (event: { request: Request; respondWith: ReturnType }) => void + ) => listeners.set(name, listener) + }, + caches: { open }, + fetch, + URL + }); + function request(path: string, method = 'GET') { + const respondWith = vi.fn(); + listeners.get('fetch')!({ + request: new Request(new URL(path, 'https://programmer.bar'), { method }), + respondWith + }); + return respondWith; + } + return { request, match, open, fetch }; +} + +describe('service worker requests', () => { + it.each([ + '/portal/fravaer', + '/portal/admin/vaktoversikt', + '/portal/fravaer/__data.json', + '/api/status', + '/@vite/client', + '/src/routes/+layout.svelte', + 'https://other.example/_app/immutable/app.js' + ])('leaves %s to the browser without serving stale cached data', (path) => { + const { request, open, fetch } = setup(); + expect(request(path)).not.toHaveBeenCalled(); + expect(open).not.toHaveBeenCalled(); + expect(fetch).not.toHaveBeenCalled(); + }); + it('does not intercept form submissions', () => { + expect(setup().request('/portal/fravaer?/recurring', 'POST')).not.toHaveBeenCalled(); + }); + it('serves a cached build asset without a network request', async () => { + const { request, match, fetch } = setup(); + const cached = new Response('cached'); + match.mockResolvedValue(cached); + const response = await request('/_app/immutable/app.js').mock.calls[0][0]; + expect(response).toBe(cached); + expect(fetch).not.toHaveBeenCalled(); + }); + it('loads a missing asset from the network', async () => { + const { request, fetch } = setup(); + const response = await request('/_app/immutable/app.js').mock.calls[0][0]; + expect(await response.text()).toBe('network'); + expect(fetch).toHaveBeenCalledOnce(); + }); + it('still loads JavaScript when cache storage fails', async () => { + const { request, open } = setup(); + open.mockRejectedValue(new Error('Storage unavailable')); + const response = await request('/_app/immutable/app.js').mock.calls[0][0]; + expect(await response.text()).toBe('network'); + }); +}); diff --git a/programmerbar-web/src/service-worker.ts b/programmerbar-web/src/service-worker.ts index 2a68e77f..f5bbfdcd 100644 --- a/programmerbar-web/src/service-worker.ts +++ b/programmerbar-web/src/service-worker.ts @@ -114,49 +114,22 @@ self.addEventListener('pushsubscriptionchange', (event) => { }); self.addEventListener('fetch', (event) => { - // ignore POST requests etc + // Only intercept this build's static assets. Portal HTML, load data and Vite + // modules must go directly to the network, never to an older cached page. if (event.request.method !== 'GET') return; + const url = new URL(event.request.url); + if (url.origin !== self.location.origin || !ASSETS.includes(url.pathname)) return; async function respond() { - const url = new URL(event.request.url); - const cache = await caches.open(CACHE); - - // `build`/`files` can always be served from the cache - if (ASSETS.includes(url.pathname)) { - const response = await cache.match(url.pathname); - - if (response) { - return response; - } - } - - // for everything else, try the network first, but - // fall back to the cache if we're offline + // Cache storage may be unavailable or full. It must not prevent loading JS. try { - const response = await fetch(event.request); - - // if we're offline, fetch can return a value that is not a Response - // instead of throwing - and we can't pass this non-Response to respondWith - if (!(response instanceof Response)) { - throw new Error('invalid response from fetch'); - } - - if (response.status === 200) { - cache.put(event.request, response.clone()); - } - - return response; - } catch (err) { - const response = await cache.match(event.request); - - if (response) { - return response; - } - - // if there's no cache, then just error out - // as there is nothing we can do to respond to this request - throw err; + const cache = await caches.open(CACHE); + const response = await cache.match(url.pathname); + if (response) return response; + } catch { + // Fall back to the network when browser storage is unavailable. } + return fetch(event.request); } event.respondWith(respond());