diff --git a/src/lib/sw/cache-strategy.ts b/src/lib/sw/cache-strategy.ts new file mode 100644 index 0000000..a1de20a --- /dev/null +++ b/src/lib/sw/cache-strategy.ts @@ -0,0 +1,42 @@ +export type CacheStrategy = 'shell' | 'swr' | 'images' | 'network-only'; + +export type RequestShape = { url: string; method: string }; + +// Pure function — sole decision-maker for "which strategy for this request?". +// Called by the service worker for every fetch event. +export function resolveStrategy(req: RequestShape): CacheStrategy { + // All write methods: never cache. + if (req.method !== 'GET' && req.method !== 'HEAD') return 'network-only'; + + // Reduce URL to pathname — query string not needed for matching + // except that online-only endpoints need no special handling here. + const path = req.url.startsWith('http') ? new URL(req.url).pathname : req.url.split('?')[0]; + + // Explicitly online-only GETs + if ( + path === '/api/recipes/import' || + path === '/api/recipes/preview' || + path.startsWith('/api/recipes/search/web') + ) { + return 'network-only'; + } + + // Images + if (path.startsWith('/images/')) return 'images'; + + // App-shell: build assets and known static files + if ( + path.startsWith('/_app/') || + path === '/manifest.webmanifest' || + path === '/icon.svg' || + path === '/icon-192.png' || + path === '/icon-512.png' || + path === '/favicon.ico' || + path === '/robots.txt' + ) { + return 'shell'; + } + + // Everything else: recipe pages, API reads, lists — all SWR. + return 'swr'; +} diff --git a/tests/unit/cache-strategy.test.ts b/tests/unit/cache-strategy.test.ts new file mode 100644 index 0000000..9a96d22 --- /dev/null +++ b/tests/unit/cache-strategy.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { resolveStrategy } from '../../src/lib/sw/cache-strategy'; + +describe('resolveStrategy', () => { + it('images bucket for /images/*', () => { + expect(resolveStrategy({ url: '/images/favicon-abc.png', method: 'GET' })).toBe('images'); + }); + + it('swr for recipe HTML pages', () => { + expect(resolveStrategy({ url: '/recipes/42', method: 'GET' })).toBe('swr'); + }); + + it('swr for recipe API reads', () => { + expect(resolveStrategy({ url: '/api/recipes/42', method: 'GET' })).toBe('swr'); + expect(resolveStrategy({ url: '/api/recipes/all?sort=name', method: 'GET' })).toBe('swr'); + expect(resolveStrategy({ url: '/api/wishlist', method: 'GET' })).toBe('swr'); + }); + + it('network-only for write methods', () => { + expect(resolveStrategy({ url: '/api/recipes/42', method: 'PATCH' })).toBe('network-only'); + expect(resolveStrategy({ url: '/api/recipes/42/favorite', method: 'PUT' })).toBe('network-only'); + expect(resolveStrategy({ url: '/api/wishlist', method: 'POST' })).toBe('network-only'); + }); + + it('network-only for online-only endpoints even on GET', () => { + expect(resolveStrategy({ url: '/api/recipes/import', method: 'GET' })).toBe('network-only'); + expect(resolveStrategy({ url: '/api/recipes/preview?url=x', method: 'GET' })).toBe('network-only'); + expect(resolveStrategy({ url: '/api/recipes/search/web?q=x', method: 'GET' })).toBe('network-only'); + }); + + it('shell bucket for build/static assets', () => { + expect(resolveStrategy({ url: '/_app/immutable/chunks/x.js', method: 'GET' })).toBe('shell'); + expect(resolveStrategy({ url: '/icon-192.png', method: 'GET' })).toBe('shell'); + expect(resolveStrategy({ url: '/manifest.webmanifest', method: 'GET' })).toBe('shell'); + }); + + it('falls through to swr for other same-origin GETs (e.g. root page)', () => { + expect(resolveStrategy({ url: '/', method: 'GET' })).toBe('swr'); + expect(resolveStrategy({ url: '/wishlist', method: 'GET' })).toBe('swr'); + }); +});