@baukit/pwa-web 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@baukit/pwa-web",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Cache strategy, cleanup, and a classic worker artifact for web PWAs.",
5
5
  "license": "MIT",
6
6
  "author": "Patrick Koss",
@@ -34,6 +34,7 @@
34
34
  },
35
35
  "files": [
36
36
  "dist",
37
+ "src",
37
38
  "README.md",
38
39
  "LICENSE"
39
40
  ],
@@ -0,0 +1,118 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { cleanupCaches } from './cache-cleanup.js';
4
+
5
+ describe('cleanupCaches', () => {
6
+ it('removes old cache versions without touching the current or unrelated caches', async () => {
7
+ const deleted: string[] = [];
8
+
9
+ await expect(
10
+ cleanupCaches({
11
+ ports: {
12
+ listCacheNames: () =>
13
+ Promise.resolve(['notes-app-v1', 'notes-app-v2', 'notes-media', 'another-product-v1']),
14
+ deleteCache: (name) => {
15
+ deleted.push(name);
16
+ return Promise.resolve(true);
17
+ },
18
+ },
19
+ shouldDelete: (name) => name.startsWith('notes-app-') && name !== 'notes-app-v2',
20
+ }),
21
+ ).resolves.toEqual({ matchedCount: 1, deletedCount: 1 });
22
+
23
+ expect(deleted).toEqual(['notes-app-v1']);
24
+ });
25
+
26
+ it('removes only the previous identity partition after an account switch', async () => {
27
+ const deleted: string[] = [];
28
+ const previousPartition = 'account-17';
29
+
30
+ await cleanupCaches({
31
+ ports: {
32
+ listCacheNames: () =>
33
+ Promise.resolve([
34
+ `notes-private-${previousPartition}`,
35
+ 'notes-private-account-42',
36
+ 'notes-static-v3',
37
+ ]),
38
+ deleteCache: (name) => {
39
+ deleted.push(name);
40
+ return Promise.resolve(true);
41
+ },
42
+ },
43
+ shouldDelete: (name) => name === `notes-private-${previousPartition}`,
44
+ });
45
+
46
+ expect(deleted).toEqual(['notes-private-account-17']);
47
+ });
48
+
49
+ it('counts a cache that disappeared during cleanup as matched but not deleted', async () => {
50
+ await expect(
51
+ cleanupCaches({
52
+ ports: {
53
+ listCacheNames: () => Promise.resolve(['notes-app-v1']),
54
+ deleteCache: () => Promise.resolve(false),
55
+ },
56
+ shouldDelete: () => true,
57
+ }),
58
+ ).resolves.toEqual({ matchedCount: 1, deletedCount: 0 });
59
+ });
60
+
61
+ it('starts all matching deletes before waiting for completion', async () => {
62
+ const started: string[] = [];
63
+ const finish: (() => void)[] = [];
64
+ const cleanup = cleanupCaches({
65
+ ports: {
66
+ listCacheNames: () => Promise.resolve(['notes-app-v1', 'notes-app-v2']),
67
+ deleteCache: (name) => {
68
+ started.push(name);
69
+ return new Promise((resolve) => {
70
+ finish.push(() => {
71
+ resolve(true);
72
+ });
73
+ });
74
+ },
75
+ },
76
+ shouldDelete: () => true,
77
+ });
78
+
79
+ await vi.waitFor(() => {
80
+ expect(started).toEqual(['notes-app-v1', 'notes-app-v2']);
81
+ });
82
+ for (const resolve of finish) {
83
+ resolve();
84
+ }
85
+ await expect(cleanup).resolves.toEqual({ matchedCount: 2, deletedCount: 2 });
86
+ });
87
+
88
+ it('does not start deletion when cache selection fails', async () => {
89
+ const deleteCache = vi.fn<(name: string) => Promise<boolean>>();
90
+
91
+ await expect(
92
+ cleanupCaches({
93
+ ports: {
94
+ listCacheNames: () => Promise.resolve(['notes-app-v1']),
95
+ deleteCache,
96
+ },
97
+ shouldDelete: () => {
98
+ throw new Error('invalid product cache policy');
99
+ },
100
+ }),
101
+ ).rejects.toThrow('invalid product cache policy');
102
+ expect(deleteCache).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('preserves cache deletion failures', async () => {
106
+ const failure = new Error('cache storage unavailable');
107
+
108
+ await expect(
109
+ cleanupCaches({
110
+ ports: {
111
+ listCacheNames: () => Promise.resolve(['notes-app-v1']),
112
+ deleteCache: () => Promise.reject(failure),
113
+ },
114
+ shouldDelete: () => true,
115
+ }),
116
+ ).rejects.toBe(failure);
117
+ });
118
+ });
@@ -0,0 +1,25 @@
1
+ export interface CacheCleanupPorts {
2
+ readonly listCacheNames: () => Promise<readonly string[]>;
3
+ readonly deleteCache: (name: string) => Promise<boolean>;
4
+ }
5
+
6
+ export interface CacheCleanupOptions {
7
+ readonly ports: CacheCleanupPorts;
8
+ readonly shouldDelete: (name: string) => boolean;
9
+ }
10
+
11
+ export interface CacheCleanupResult {
12
+ readonly matchedCount: number;
13
+ readonly deletedCount: number;
14
+ }
15
+
16
+ export async function cleanupCaches(options: CacheCleanupOptions): Promise<CacheCleanupResult> {
17
+ const names = await options.ports.listCacheNames();
18
+ const selected = names.filter(options.shouldDelete);
19
+ const deleted = await Promise.all(selected.map((name) => options.ports.deleteCache(name)));
20
+
21
+ return {
22
+ matchedCount: selected.length,
23
+ deletedCount: deleted.filter(Boolean).length,
24
+ };
25
+ }
@@ -0,0 +1,184 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ CACHE_STRATEGIES,
5
+ createCacheStrategyDecider,
6
+ decideCacheStrategy,
7
+ } from './cache-strategy.js';
8
+
9
+ const APP_ORIGIN = 'https://app.example.com';
10
+ const OPTIONS = { appOrigin: APP_ORIGIN };
11
+
12
+ describe('decideCacheStrategy', () => {
13
+ it('treats non-GET methods as network-only', () => {
14
+ for (const method of ['POST', 'put', 'DELETE', 'PATCH']) {
15
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/plans`, method }, OPTIONS)).toBe(
16
+ CACHE_STRATEGIES.networkOnly,
17
+ );
18
+ }
19
+ });
20
+
21
+ it('defaults to GET when the method is omitted', () => {
22
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/api/v1/me` }, OPTIONS)).toBe(
23
+ CACHE_STRATEGIES.networkFirst,
24
+ );
25
+ });
26
+
27
+ it('caches no API path by default beyond network-first', () => {
28
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/api` }, OPTIONS)).toBe(
29
+ CACHE_STRATEGIES.networkFirst,
30
+ );
31
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/api/v1/me` }, OPTIONS)).toBe(
32
+ CACHE_STRATEGIES.networkFirst,
33
+ );
34
+ });
35
+
36
+ it('does not treat a path that merely shares a prefix as an API path', () => {
37
+ expect(
38
+ decideCacheStrategy({ url: `${APP_ORIGIN}/apiary/photo.png`, destination: 'image' }, OPTIONS),
39
+ ).toBe(CACHE_STRATEGIES.cacheFirst);
40
+ });
41
+
42
+ it('never caches a configured sync path and its subpaths', () => {
43
+ const options = { appOrigin: APP_ORIGIN, neverCachedPathPrefixes: ['/api/v1/sync'] };
44
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/api/v1/sync` }, options)).toBe(
45
+ CACHE_STRATEGIES.networkOnly,
46
+ );
47
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/api/v1/sync/pull?since=12` }, options)).toBe(
48
+ CACHE_STRATEGIES.networkOnly,
49
+ );
50
+ expect(
51
+ decideCacheStrategy({ url: `${APP_ORIGIN}/api/v1/sync/push`, method: 'POST' }, options),
52
+ ).toBe(CACHE_STRATEGIES.networkOnly);
53
+ });
54
+
55
+ it('leaves the sync path unconfigured by default', () => {
56
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/api/v1/sync` }, OPTIONS)).toBe(
57
+ CACHE_STRATEGIES.networkFirst,
58
+ );
59
+ });
60
+
61
+ it('accepts a trailing slash on a configured prefix', () => {
62
+ const options = { appOrigin: APP_ORIGIN, neverCachedPathPrefixes: ['/api/v1/sync/'] };
63
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/api/v1/sync` }, options)).toBe(
64
+ CACHE_STRATEGIES.networkOnly,
65
+ );
66
+ });
67
+
68
+ it('ignores an empty prefix entry', () => {
69
+ const options = { appOrigin: APP_ORIGIN, neverCachedPathPrefixes: [''] };
70
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/plans` }, options)).toBe(
71
+ CACHE_STRATEGIES.networkFirst,
72
+ );
73
+ });
74
+
75
+ it('uses network-first for navigations even when the path looks static', () => {
76
+ expect(
77
+ decideCacheStrategy({ url: `${APP_ORIGIN}/report.json`, mode: 'navigate' }, OPTIONS),
78
+ ).toBe(CACHE_STRATEGIES.networkFirst);
79
+ });
80
+
81
+ it('keeps navigation classification network-first when a fallback is configured', () => {
82
+ const options = { appOrigin: APP_ORIGIN, navigationFallback: '/index.html' };
83
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/plans`, mode: 'navigate' }, options)).toBe(
84
+ CACHE_STRATEGIES.networkFirst,
85
+ );
86
+ });
87
+
88
+ it('uses cache-first for same-origin static destinations', () => {
89
+ for (const destination of ['audio', 'font', 'image', 'script', 'style', 'video', 'worker']) {
90
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/asset`, destination }, OPTIONS)).toBe(
91
+ CACHE_STRATEGIES.cacheFirst,
92
+ );
93
+ }
94
+ });
95
+
96
+ it('uses cache-first for same-origin static extensions without a destination', () => {
97
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/icon-512.png` }, OPTIONS)).toBe(
98
+ CACHE_STRATEGIES.cacheFirst,
99
+ );
100
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/manifest.webmanifest` }, OPTIONS)).toBe(
101
+ CACHE_STRATEGIES.cacheFirst,
102
+ );
103
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/app.a1b2c3.JS` }, OPTIONS)).toBe(
104
+ CACHE_STRATEGIES.cacheFirst,
105
+ );
106
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/bundle.js?v=3` }, OPTIONS)).toBe(
107
+ CACHE_STRATEGIES.cacheFirst,
108
+ );
109
+ });
110
+
111
+ it('does not read an extension out of a directory segment or a dotfile', () => {
112
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/v1.2/plans` }, OPTIONS)).toBe(
113
+ CACHE_STRATEGIES.networkFirst,
114
+ );
115
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/.png` }, OPTIONS)).toBe(
116
+ CACHE_STRATEGIES.networkFirst,
117
+ );
118
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/plans` }, OPTIONS)).toBe(
119
+ CACHE_STRATEGIES.networkFirst,
120
+ );
121
+ });
122
+
123
+ it('uses cache-first for a configured static path prefix with no extension', () => {
124
+ const options = { appOrigin: APP_ORIGIN, staticPathPrefixes: ['/_expo/static/'] };
125
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/_expo/static/js/web/app` }, options)).toBe(
126
+ CACHE_STRATEGIES.cacheFirst,
127
+ );
128
+ });
129
+
130
+ it('keeps cross-origin static assets on the default strategy', () => {
131
+ expect(
132
+ decideCacheStrategy(
133
+ { url: 'https://cdn.example.com/logo.png', destination: 'image' },
134
+ OPTIONS,
135
+ ),
136
+ ).toBe(CACHE_STRATEGIES.networkFirst);
137
+ });
138
+
139
+ it('resolves a relative URL against the app origin', () => {
140
+ expect(decideCacheStrategy({ url: '/icon-192.png', destination: 'image' }, OPTIONS)).toBe(
141
+ CACHE_STRATEGIES.cacheFirst,
142
+ );
143
+ });
144
+
145
+ it('honors overridden strategies and destination lists', () => {
146
+ const options = {
147
+ appOrigin: APP_ORIGIN,
148
+ apiPathPrefixes: ['/graphql'],
149
+ apiStrategy: CACHE_STRATEGIES.networkOnly,
150
+ staticStrategy: CACHE_STRATEGIES.staleWhileRevalidate,
151
+ defaultStrategy: CACHE_STRATEGIES.staleWhileRevalidate,
152
+ staticDestinations: ['image'],
153
+ staticExtensions: ['png'],
154
+ } as const;
155
+
156
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/graphql` }, options)).toBe(
157
+ CACHE_STRATEGIES.networkOnly,
158
+ );
159
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/api/v1/me` }, options)).toBe(
160
+ CACHE_STRATEGIES.staleWhileRevalidate,
161
+ );
162
+ expect(decideCacheStrategy({ url: `${APP_ORIGIN}/logo.png` }, options)).toBe(
163
+ CACHE_STRATEGIES.staleWhileRevalidate,
164
+ );
165
+ expect(
166
+ decideCacheStrategy({ url: `${APP_ORIGIN}/app.js`, destination: 'script' }, options),
167
+ ).toBe(CACHE_STRATEGIES.staleWhileRevalidate);
168
+ });
169
+ });
170
+
171
+ describe('createCacheStrategyDecider', () => {
172
+ it('binds options once and classifies many requests', () => {
173
+ const decide = createCacheStrategyDecider({
174
+ appOrigin: APP_ORIGIN,
175
+ neverCachedPathPrefixes: ['/api/v1/sync'],
176
+ });
177
+
178
+ expect(decide({ url: `${APP_ORIGIN}/api/v1/sync/pull` })).toBe(CACHE_STRATEGIES.networkOnly);
179
+ expect(decide({ url: `${APP_ORIGIN}/icon.png` })).toBe(CACHE_STRATEGIES.cacheFirst);
180
+ expect(decide({ url: `${APP_ORIGIN}/plans`, mode: 'navigate' })).toBe(
181
+ CACHE_STRATEGIES.networkFirst,
182
+ );
183
+ });
184
+ });
@@ -0,0 +1,156 @@
1
+ export const CACHE_STRATEGIES = {
2
+ cacheFirst: 'cache-first',
3
+ networkFirst: 'network-first',
4
+ networkOnly: 'network-only',
5
+ staleWhileRevalidate: 'stale-while-revalidate',
6
+ } as const;
7
+
8
+ export type CacheStrategy = (typeof CACHE_STRATEGIES)[keyof typeof CACHE_STRATEGIES];
9
+
10
+ export interface CacheStrategyRequest {
11
+ readonly url: string;
12
+ readonly method?: string;
13
+ readonly mode?: string;
14
+ readonly destination?: string;
15
+ }
16
+
17
+ export interface CacheStrategyOptions {
18
+ readonly appOrigin: string;
19
+ readonly apiPathPrefixes?: readonly string[];
20
+ readonly neverCachedPathPrefixes?: readonly string[];
21
+ readonly navigationFallback?: string;
22
+ readonly staticPathPrefixes?: readonly string[];
23
+ readonly staticDestinations?: readonly string[];
24
+ readonly staticExtensions?: readonly string[];
25
+ readonly apiStrategy?: CacheStrategy;
26
+ readonly staticStrategy?: CacheStrategy;
27
+ readonly defaultStrategy?: CacheStrategy;
28
+ }
29
+
30
+ export interface CacheStrategyDecision {
31
+ readonly strategy: CacheStrategy;
32
+ readonly navigationFallback?: string;
33
+ }
34
+
35
+ const DEFAULT_API_PATH_PREFIXES: readonly string[] = ['/api'];
36
+
37
+ const DEFAULT_STATIC_DESTINATIONS: readonly string[] = [
38
+ 'audio',
39
+ 'font',
40
+ 'image',
41
+ 'script',
42
+ 'style',
43
+ 'video',
44
+ 'worker',
45
+ ];
46
+
47
+ const DEFAULT_STATIC_EXTENSIONS: readonly string[] = [
48
+ 'avif',
49
+ 'css',
50
+ 'gif',
51
+ 'ico',
52
+ 'jpeg',
53
+ 'jpg',
54
+ 'js',
55
+ 'json',
56
+ 'mjs',
57
+ 'mp3',
58
+ 'mp4',
59
+ 'ogg',
60
+ 'otf',
61
+ 'png',
62
+ 'svg',
63
+ 'ttf',
64
+ 'webmanifest',
65
+ 'webp',
66
+ 'woff',
67
+ 'woff2',
68
+ ];
69
+
70
+ function matchesPathPrefix(pathname: string, prefix: string): boolean {
71
+ if (prefix.length === 0) {
72
+ return false;
73
+ }
74
+ const normalized = prefix.endsWith('/') ? prefix.slice(0, -1) : prefix;
75
+ return pathname === normalized || pathname.startsWith(`${normalized}/`);
76
+ }
77
+
78
+ function matchesAnyPathPrefix(pathname: string, prefixes: readonly string[]): boolean {
79
+ return prefixes.some((prefix) => matchesPathPrefix(pathname, prefix));
80
+ }
81
+
82
+ function hasStaticExtension(pathname: string, extensions: readonly string[]): boolean {
83
+ const lastSegment = pathname.slice(pathname.lastIndexOf('/') + 1);
84
+ const dotIndex = lastSegment.lastIndexOf('.');
85
+ if (dotIndex <= 0) {
86
+ return false;
87
+ }
88
+ const extension = lastSegment.slice(dotIndex + 1).toLowerCase();
89
+ return extensions.includes(extension);
90
+ }
91
+
92
+ function startsWithAny(pathname: string, prefixes: readonly string[]): boolean {
93
+ return prefixes.some((prefix) => prefix.length > 0 && pathname.startsWith(prefix));
94
+ }
95
+
96
+ export function decideCacheStrategyDecision(
97
+ request: CacheStrategyRequest,
98
+ options: CacheStrategyOptions,
99
+ ): CacheStrategyDecision {
100
+ const {
101
+ appOrigin,
102
+ apiPathPrefixes = DEFAULT_API_PATH_PREFIXES,
103
+ navigationFallback,
104
+ neverCachedPathPrefixes = [],
105
+ staticPathPrefixes = [],
106
+ staticDestinations = DEFAULT_STATIC_DESTINATIONS,
107
+ staticExtensions = DEFAULT_STATIC_EXTENSIONS,
108
+ apiStrategy = CACHE_STRATEGIES.networkFirst,
109
+ staticStrategy = CACHE_STRATEGIES.cacheFirst,
110
+ defaultStrategy = CACHE_STRATEGIES.networkFirst,
111
+ } = options;
112
+
113
+ const parsed = new URL(request.url, appOrigin);
114
+ const { pathname } = parsed;
115
+ const sameOrigin = parsed.origin === appOrigin;
116
+
117
+ if (matchesAnyPathPrefix(pathname, neverCachedPathPrefixes)) {
118
+ return { strategy: CACHE_STRATEGIES.networkOnly };
119
+ }
120
+
121
+ const method = (request.method ?? 'GET').toUpperCase();
122
+ if (method !== 'GET') {
123
+ return { strategy: CACHE_STRATEGIES.networkOnly };
124
+ }
125
+
126
+ if (matchesAnyPathPrefix(pathname, apiPathPrefixes)) {
127
+ return { strategy: apiStrategy };
128
+ }
129
+
130
+ if (request.mode === 'navigate') {
131
+ if (navigationFallback !== undefined && sameOrigin) {
132
+ return { strategy: defaultStrategy, navigationFallback };
133
+ }
134
+ return { strategy: defaultStrategy };
135
+ }
136
+
137
+ const staticAsset =
138
+ (request.destination !== undefined && staticDestinations.includes(request.destination)) ||
139
+ hasStaticExtension(pathname, staticExtensions) ||
140
+ startsWithAny(pathname, staticPathPrefixes);
141
+
142
+ return { strategy: sameOrigin && staticAsset ? staticStrategy : defaultStrategy };
143
+ }
144
+
145
+ export function decideCacheStrategy(
146
+ request: CacheStrategyRequest,
147
+ options: CacheStrategyOptions,
148
+ ): CacheStrategy {
149
+ return decideCacheStrategyDecision(request, options).strategy;
150
+ }
151
+
152
+ export function createCacheStrategyDecider(
153
+ options: CacheStrategyOptions,
154
+ ): (request: CacheStrategyRequest) => CacheStrategy {
155
+ return (request) => decideCacheStrategy(request, options);
156
+ }
@@ -0,0 +1,279 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { CACHE_STRATEGIES, type CacheStrategy } from './cache-strategy.js';
4
+ import {
5
+ createFetchHandler,
6
+ type FetchHandlerPorts,
7
+ type FetchLikeRequest,
8
+ } from './fetch-handler.js';
9
+
10
+ const APP_ORIGIN = 'https://app.example.com';
11
+
12
+ interface TestResponse {
13
+ readonly body: string;
14
+ readonly cacheable?: boolean;
15
+ }
16
+
17
+ interface Harness {
18
+ readonly ports: FetchHandlerPorts<FetchLikeRequest, TestResponse>;
19
+ readonly puts: { strategy: CacheStrategy; url: string; body: string }[];
20
+ readonly fetchCalls: { url: string; noStore: boolean }[];
21
+ readonly matchCalls: string[];
22
+ }
23
+
24
+ function harness(overrides: {
25
+ cached?: TestResponse;
26
+ cachedByUrl?: Readonly<Record<string, TestResponse>>;
27
+ network?: TestResponse | (() => Promise<TestResponse>);
28
+ }): Harness {
29
+ const puts: Harness['puts'] = [];
30
+ const fetchCalls: Harness['fetchCalls'] = [];
31
+ const matchCalls: string[] = [];
32
+ const network = overrides.network ?? { body: 'network' };
33
+
34
+ return {
35
+ puts,
36
+ fetchCalls,
37
+ matchCalls,
38
+ ports: {
39
+ fetch: async (request, init) => {
40
+ fetchCalls.push({ url: request.url, noStore: init?.cache === 'no-store' });
41
+ return typeof network === 'function' ? network() : network;
42
+ },
43
+ matchCache: (request) => {
44
+ const url = typeof request === 'string' ? request : request.url;
45
+ matchCalls.push(url);
46
+ return Promise.resolve(overrides.cachedByUrl?.[url] ?? overrides.cached);
47
+ },
48
+ putCache: (strategy, request, response) => {
49
+ puts.push({ strategy, url: request.url, body: response.body });
50
+ return Promise.resolve();
51
+ },
52
+ isCacheable: (response) => response.cacheable !== false,
53
+ cloneResponse: (response) => ({ ...response }),
54
+ },
55
+ };
56
+ }
57
+
58
+ describe('createFetchHandler', () => {
59
+ it('bypasses the cache for a never-cached path', async () => {
60
+ const { ports, fetchCalls, puts } = harness({ cached: { body: 'stale' } });
61
+ const handle = createFetchHandler({
62
+ appOrigin: APP_ORIGIN,
63
+ neverCachedPathPrefixes: ['/api/v1/sync'],
64
+ ports,
65
+ });
66
+
67
+ await expect(handle({ url: `${APP_ORIGIN}/api/v1/sync/pull` })).resolves.toEqual({
68
+ body: 'network',
69
+ });
70
+ expect(fetchCalls).toEqual([{ url: `${APP_ORIGIN}/api/v1/sync/pull`, noStore: true }]);
71
+ expect(puts).toEqual([]);
72
+ });
73
+
74
+ it('serves a cache-first hit without touching the network', async () => {
75
+ const { ports, fetchCalls } = harness({ cached: { body: 'cached-icon' } });
76
+ const handle = createFetchHandler({ appOrigin: APP_ORIGIN, ports });
77
+
78
+ await expect(handle({ url: `${APP_ORIGIN}/icon.png` })).resolves.toEqual({
79
+ body: 'cached-icon',
80
+ });
81
+ expect(fetchCalls).toEqual([]);
82
+ });
83
+
84
+ it('fetches and stores a cache-first miss', async () => {
85
+ const { ports, puts } = harness({});
86
+ const handle = createFetchHandler({ appOrigin: APP_ORIGIN, ports });
87
+
88
+ await expect(handle({ url: `${APP_ORIGIN}/icon.png` })).resolves.toEqual({ body: 'network' });
89
+ expect(puts).toEqual([
90
+ { strategy: CACHE_STRATEGIES.cacheFirst, url: `${APP_ORIGIN}/icon.png`, body: 'network' },
91
+ ]);
92
+ });
93
+
94
+ it('does not store an uncacheable cache-first response', async () => {
95
+ const { ports, puts } = harness({ network: { body: 'error', cacheable: false } });
96
+ const handle = createFetchHandler({ appOrigin: APP_ORIGIN, ports });
97
+
98
+ await expect(handle({ url: `${APP_ORIGIN}/icon.png` })).resolves.toEqual({
99
+ body: 'error',
100
+ cacheable: false,
101
+ });
102
+ expect(puts).toEqual([]);
103
+ });
104
+
105
+ it('stores a successful network-first response', async () => {
106
+ const { ports, puts } = harness({});
107
+ const handle = createFetchHandler({ appOrigin: APP_ORIGIN, ports });
108
+
109
+ await expect(handle({ url: `${APP_ORIGIN}/plans`, mode: 'navigate' })).resolves.toEqual({
110
+ body: 'network',
111
+ });
112
+ expect(puts).toEqual([
113
+ { strategy: CACHE_STRATEGIES.networkFirst, url: `${APP_ORIGIN}/plans`, body: 'network' },
114
+ ]);
115
+ });
116
+
117
+ it('falls back to the cache when a network-first fetch fails', async () => {
118
+ const { ports } = harness({
119
+ cached: { body: 'shell' },
120
+ network: () => Promise.reject(new Error('offline')),
121
+ });
122
+ const handle = createFetchHandler({ appOrigin: APP_ORIGIN, ports });
123
+
124
+ await expect(handle({ url: `${APP_ORIGIN}/plans`, mode: 'navigate' })).resolves.toEqual({
125
+ body: 'shell',
126
+ });
127
+ });
128
+
129
+ it('uses the configured app shell after a navigation request misses the cache', async () => {
130
+ const { ports, matchCalls } = harness({
131
+ cachedByUrl: { '/index.html': { body: 'app-shell' } },
132
+ network: () => Promise.reject(new Error('offline')),
133
+ });
134
+ const handle = createFetchHandler({
135
+ appOrigin: APP_ORIGIN,
136
+ navigationFallback: '/index.html',
137
+ ports,
138
+ });
139
+
140
+ await expect(handle({ url: `${APP_ORIGIN}/plans/active`, mode: 'navigate' })).resolves.toEqual({
141
+ body: 'app-shell',
142
+ });
143
+ expect(matchCalls).toEqual([`${APP_ORIGIN}/plans/active`, '/index.html']);
144
+ });
145
+
146
+ it('runs an overridden navigation strategy before the fallback lookup', async () => {
147
+ const { ports, matchCalls } = harness({
148
+ cachedByUrl: { '/index.html': { body: 'app-shell' } },
149
+ network: () => Promise.reject(new Error('offline')),
150
+ });
151
+ const handle = createFetchHandler({
152
+ appOrigin: APP_ORIGIN,
153
+ defaultStrategy: CACHE_STRATEGIES.cacheFirst,
154
+ navigationFallback: '/index.html',
155
+ ports,
156
+ });
157
+
158
+ await expect(handle({ url: `${APP_ORIGIN}/plans`, mode: 'navigate' })).resolves.toEqual({
159
+ body: 'app-shell',
160
+ });
161
+ expect(matchCalls).toEqual([`${APP_ORIGIN}/plans`, '/index.html']);
162
+ });
163
+
164
+ it('does not use the navigation fallback for an API route', async () => {
165
+ const { ports, matchCalls } = harness({
166
+ cachedByUrl: { '/index.html': { body: 'app-shell' } },
167
+ network: () => Promise.reject(new Error('offline')),
168
+ });
169
+ const handle = createFetchHandler({
170
+ appOrigin: APP_ORIGIN,
171
+ navigationFallback: '/index.html',
172
+ ports,
173
+ });
174
+
175
+ await expect(handle({ url: `${APP_ORIGIN}/api/v1/me`, mode: 'navigate' })).rejects.toThrow(
176
+ 'offline',
177
+ );
178
+ expect(matchCalls).toEqual([`${APP_ORIGIN}/api/v1/me`]);
179
+ });
180
+
181
+ it('does not use the navigation fallback for a non-navigation request', async () => {
182
+ const { ports, matchCalls } = harness({
183
+ cachedByUrl: { '/index.html': { body: 'app-shell' } },
184
+ network: () => Promise.reject(new Error('offline')),
185
+ });
186
+ const handle = createFetchHandler({
187
+ appOrigin: APP_ORIGIN,
188
+ navigationFallback: '/index.html',
189
+ ports,
190
+ });
191
+
192
+ await expect(handle({ url: `${APP_ORIGIN}/plans` })).rejects.toThrow('offline');
193
+ expect(matchCalls).toEqual([`${APP_ORIGIN}/plans`]);
194
+ });
195
+
196
+ it('keeps navigation misses unchanged when no fallback is configured', async () => {
197
+ const { ports, matchCalls } = harness({
198
+ network: () => Promise.reject(new Error('offline')),
199
+ });
200
+ const handle = createFetchHandler({ appOrigin: APP_ORIGIN, ports });
201
+
202
+ await expect(handle({ url: `${APP_ORIGIN}/plans`, mode: 'navigate' })).rejects.toThrow(
203
+ 'offline',
204
+ );
205
+ expect(matchCalls).toEqual([`${APP_ORIGIN}/plans`]);
206
+ });
207
+
208
+ it('rethrows when a network-first fetch fails with nothing cached', async () => {
209
+ const { ports } = harness({ network: () => Promise.reject(new Error('offline')) });
210
+ const handle = createFetchHandler({ appOrigin: APP_ORIGIN, ports });
211
+
212
+ await expect(handle({ url: `${APP_ORIGIN}/api/v1/me` })).rejects.toThrow('offline');
213
+ });
214
+
215
+ it('returns the cached response and revalidates in the background', async () => {
216
+ const { ports, puts } = harness({ cached: { body: 'stale' } });
217
+ const handle = createFetchHandler({
218
+ appOrigin: APP_ORIGIN,
219
+ staticStrategy: CACHE_STRATEGIES.staleWhileRevalidate,
220
+ ports,
221
+ });
222
+
223
+ await expect(handle({ url: `${APP_ORIGIN}/icon.png` })).resolves.toEqual({ body: 'stale' });
224
+ await vi.waitFor(() => {
225
+ expect(puts).toEqual([
226
+ {
227
+ strategy: CACHE_STRATEGIES.staleWhileRevalidate,
228
+ url: `${APP_ORIGIN}/icon.png`,
229
+ body: 'network',
230
+ },
231
+ ]);
232
+ });
233
+ });
234
+
235
+ it('awaits the network on a stale-while-revalidate miss', async () => {
236
+ const { ports, puts } = harness({});
237
+ const handle = createFetchHandler({
238
+ appOrigin: APP_ORIGIN,
239
+ staticStrategy: CACHE_STRATEGIES.staleWhileRevalidate,
240
+ ports,
241
+ });
242
+
243
+ await expect(handle({ url: `${APP_ORIGIN}/icon.png` })).resolves.toEqual({ body: 'network' });
244
+ expect(puts).toHaveLength(1);
245
+ });
246
+
247
+ it('reports a background revalidation failure instead of rejecting', async () => {
248
+ const onRevalidateError = vi.fn();
249
+ const { ports } = harness({
250
+ cached: { body: 'stale' },
251
+ network: () => Promise.reject(new Error('offline')),
252
+ });
253
+ const handle = createFetchHandler({
254
+ appOrigin: APP_ORIGIN,
255
+ staticStrategy: CACHE_STRATEGIES.staleWhileRevalidate,
256
+ ports,
257
+ onRevalidateError,
258
+ });
259
+
260
+ await expect(handle({ url: `${APP_ORIGIN}/icon.png` })).resolves.toEqual({ body: 'stale' });
261
+ await vi.waitFor(() => {
262
+ expect(onRevalidateError).toHaveBeenCalledWith(new Error('offline'));
263
+ });
264
+ });
265
+
266
+ it('swallows a background revalidation failure with no reporter', async () => {
267
+ const { ports } = harness({
268
+ cached: { body: 'stale' },
269
+ network: () => Promise.reject(new Error('offline')),
270
+ });
271
+ const handle = createFetchHandler({
272
+ appOrigin: APP_ORIGIN,
273
+ staticStrategy: CACHE_STRATEGIES.staleWhileRevalidate,
274
+ ports,
275
+ });
276
+
277
+ await expect(handle({ url: `${APP_ORIGIN}/icon.png` })).resolves.toEqual({ body: 'stale' });
278
+ });
279
+ });
@@ -0,0 +1,124 @@
1
+ import {
2
+ CACHE_STRATEGIES,
3
+ decideCacheStrategyDecision,
4
+ type CacheStrategy,
5
+ type CacheStrategyOptions,
6
+ type CacheStrategyRequest,
7
+ } from './cache-strategy.js';
8
+
9
+ export interface FetchLikeRequest extends CacheStrategyRequest {
10
+ readonly url: string;
11
+ }
12
+
13
+ export interface FetchHandlerPorts<TRequest extends FetchLikeRequest, TResponse> {
14
+ readonly fetch: (request: TRequest, init?: { readonly cache?: 'no-store' }) => Promise<TResponse>;
15
+ readonly matchCache: (request: TRequest | string) => Promise<TResponse | undefined>;
16
+ readonly putCache: (
17
+ strategy: CacheStrategy,
18
+ request: TRequest,
19
+ response: TResponse,
20
+ ) => Promise<void>;
21
+ readonly isCacheable: (response: TResponse) => boolean;
22
+ readonly cloneResponse: (response: TResponse) => TResponse;
23
+ }
24
+
25
+ export interface FetchHandlerOptions<
26
+ TRequest extends FetchLikeRequest,
27
+ TResponse,
28
+ > extends CacheStrategyOptions {
29
+ readonly ports: FetchHandlerPorts<TRequest, TResponse>;
30
+ readonly onRevalidateError?: (error: unknown) => void;
31
+ }
32
+
33
+ async function cacheFirst<TRequest extends FetchLikeRequest, TResponse>(
34
+ request: TRequest,
35
+ ports: FetchHandlerPorts<TRequest, TResponse>,
36
+ ): Promise<TResponse> {
37
+ const cached = await ports.matchCache(request);
38
+ if (cached !== undefined) {
39
+ return cached;
40
+ }
41
+
42
+ const response = await ports.fetch(request);
43
+ if (ports.isCacheable(response)) {
44
+ await ports.putCache(CACHE_STRATEGIES.cacheFirst, request, ports.cloneResponse(response));
45
+ }
46
+ return response;
47
+ }
48
+
49
+ async function networkFirst<TRequest extends FetchLikeRequest, TResponse>(
50
+ request: TRequest,
51
+ ports: FetchHandlerPorts<TRequest, TResponse>,
52
+ ): Promise<TResponse> {
53
+ try {
54
+ const response = await ports.fetch(request);
55
+ if (ports.isCacheable(response)) {
56
+ await ports.putCache(CACHE_STRATEGIES.networkFirst, request, ports.cloneResponse(response));
57
+ }
58
+ return response;
59
+ } catch (error) {
60
+ const cached = await ports.matchCache(request);
61
+ if (cached !== undefined) {
62
+ return cached;
63
+ }
64
+ throw error;
65
+ }
66
+ }
67
+
68
+ async function staleWhileRevalidate<TRequest extends FetchLikeRequest, TResponse>(
69
+ request: TRequest,
70
+ ports: FetchHandlerPorts<TRequest, TResponse>,
71
+ onRevalidateError: ((error: unknown) => void) | undefined,
72
+ ): Promise<TResponse> {
73
+ const cached = await ports.matchCache(request);
74
+ const revalidate = (async () => {
75
+ const response = await ports.fetch(request);
76
+ if (ports.isCacheable(response)) {
77
+ await ports.putCache(
78
+ CACHE_STRATEGIES.staleWhileRevalidate,
79
+ request,
80
+ ports.cloneResponse(response),
81
+ );
82
+ }
83
+ return response;
84
+ })();
85
+
86
+ if (cached === undefined) {
87
+ return revalidate;
88
+ }
89
+
90
+ revalidate.catch((error: unknown) => {
91
+ onRevalidateError?.(error);
92
+ });
93
+ return cached;
94
+ }
95
+
96
+ export function createFetchHandler<TRequest extends FetchLikeRequest, TResponse>(
97
+ options: FetchHandlerOptions<TRequest, TResponse>,
98
+ ): (request: TRequest) => Promise<TResponse> {
99
+ const { ports, onRevalidateError, ...strategyOptions } = options;
100
+
101
+ return async (request) => {
102
+ const { strategy, navigationFallback } = decideCacheStrategyDecision(request, strategyOptions);
103
+ try {
104
+ switch (strategy) {
105
+ case CACHE_STRATEGIES.networkOnly:
106
+ return await ports.fetch(request, { cache: 'no-store' });
107
+ case CACHE_STRATEGIES.cacheFirst:
108
+ return await cacheFirst(request, ports);
109
+ case CACHE_STRATEGIES.staleWhileRevalidate:
110
+ return await staleWhileRevalidate(request, ports, onRevalidateError);
111
+ case CACHE_STRATEGIES.networkFirst:
112
+ return await networkFirst(request, ports);
113
+ }
114
+ } catch (error) {
115
+ if (navigationFallback !== undefined) {
116
+ const fallback = await ports.matchCache(navigationFallback);
117
+ if (fallback !== undefined) {
118
+ return fallback;
119
+ }
120
+ }
121
+ throw error;
122
+ }
123
+ };
124
+ }
package/src/index.ts ADDED
@@ -0,0 +1,20 @@
1
+ export {
2
+ cleanupCaches,
3
+ type CacheCleanupOptions,
4
+ type CacheCleanupPorts,
5
+ type CacheCleanupResult,
6
+ } from './cache-cleanup.js';
7
+ export {
8
+ CACHE_STRATEGIES,
9
+ createCacheStrategyDecider,
10
+ decideCacheStrategy,
11
+ type CacheStrategy,
12
+ type CacheStrategyOptions,
13
+ type CacheStrategyRequest,
14
+ } from './cache-strategy.js';
15
+ export {
16
+ createFetchHandler,
17
+ type FetchHandlerOptions,
18
+ type FetchHandlerPorts,
19
+ type FetchLikeRequest,
20
+ } from './fetch-handler.js';
@@ -0,0 +1 @@
1
+ export * from './index.js';