Skip to content
Merged
Show file tree
Hide file tree
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
86 changes: 86 additions & 0 deletions programmerbar-web/src/lib/service-worker.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof vi.fn> }) => 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<typeof vi.fn> }) => 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');
});
});
49 changes: 11 additions & 38 deletions programmerbar-web/src/service-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
Loading