Skip to content

Commit 5c9386e

Browse files
committed
test sw
1 parent 7d78f3b commit 5c9386e

7 files changed

Lines changed: 521 additions & 2 deletions

File tree

-72.5 KB
Loading

app/asset/img/livecodes-logo.svg

Lines changed: 0 additions & 1 deletion
This file was deleted.

app/asset/manifest.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
{
22
"short_name": "Codemo",
33
"name": "Codemo Digital Nomad",
4+
"lang": "en-US",
45
"icons": [
56
{
67
"src": "https://gigamaster.github.io/codemo/asset/favicon/apple-touch-icon.png",

app/asset/service-worker.js

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
// service worker version number
2+
const SW_VERSION = 4;
3+
4+
// cache name including version number
5+
const cacheName = `web-app-cache-${SW_VERSION}`;
6+
7+
// static files to cache
8+
const staticFiles = [
9+
'/codemo/asset/sw-registration.js',
10+
'/codemo/index.html',
11+
'/codemo/about/index.html',
12+
'/codemo/asset/manifest.json',
13+
'/codemo/asset/offline.html',
14+
'/codemo/asset/favicon/android-chrome-192x192.png',
15+
'/codemo/asset/favicon/android-chrome-512x512.png',
16+
];
17+
18+
// routes to cache
19+
const routes = [
20+
'/codemo',
21+
'/codemo/tools/tasks/index.html',
22+
'/codemo/tools/notes/',
23+
];
24+
25+
// combine static files and routes to cache
26+
const filesToCache = [
27+
...routes,
28+
...staticFiles,
29+
];
30+
31+
const requestsToRetryWhenOffline = [];
32+
33+
const IDBConfig = {
34+
name: 'web-app-db',
35+
version: SW_VERSION,
36+
stores: {
37+
requestStore: {
38+
name: `request-store`,
39+
keyPath: 'timestamp'
40+
}
41+
}
42+
};
43+
44+
// returns if the app is offline
45+
const isOffline = () => !self.navigator.onLine;
46+
47+
// return if a request should be retried when offline, in this example, all POST, PUT, DELETE requests
48+
// and requests that are listed in the requestsToRetryWhenOffline array
49+
// you can adapt this function to your specific needs
50+
const isRequestEligibleForRetry = ({url, method}) => {
51+
return ['POST', 'PUT', 'DELETE'].includes(method) || requestsToRetryWhenOffline.includes(url);
52+
};
53+
54+
const createIndexedDB = ({name, stores}) => {
55+
const request = self.indexedDB.open(name, 1);
56+
57+
return new Promise((resolve, reject) => {
58+
request.onupgradeneeded = e => {
59+
const db = e.target.result;
60+
61+
Object.keys(stores).forEach((store) => {
62+
const {name, keyPath} = stores[store];
63+
64+
if(!db.objectStoreNames.contains(name)) {
65+
db.createObjectStore(name, {keyPath});
66+
console.log('create objectstore', name);
67+
}
68+
});
69+
};
70+
71+
request.onsuccess = () => resolve(request.result);
72+
request.onerror = () => reject(request.error);
73+
});
74+
};
75+
76+
const getStoreFactory = (dbName) => ({name}, mode = 'readonly') => {
77+
return new Promise((resolve, reject) => {
78+
79+
const request = self.indexedDB.open(dbName, 1);
80+
81+
request.onsuccess = e => {
82+
const db = request.result;
83+
const transaction = db.transaction(name, mode);
84+
const store = transaction.objectStore(name);
85+
86+
// return a proxy object for the IDBObjectStore, allowing for promise-based access to methods
87+
const storeProxy = new Proxy(store, {
88+
get(target, prop) {
89+
if(typeof target[prop] === 'function') {
90+
return (...args) => new Promise((resolve, reject) => {
91+
const req = target[prop].apply(target, args);
92+
93+
req.onsuccess = () => resolve(req.result);
94+
req.onerror = err => reject(err);
95+
});
96+
}
97+
98+
return target[prop];
99+
},
100+
});
101+
102+
return resolve(storeProxy);
103+
};
104+
105+
request.onerror = e => reject(request.error);
106+
});
107+
};
108+
109+
const openStore = getStoreFactory(IDBConfig.name);
110+
111+
// serialize request headers for storage in IndexedDB
112+
const serializeHeaders = (headers) => [...headers.entries()].reduce((acc, [key, value]) => ({
113+
...acc,
114+
[key]: value
115+
}), {});
116+
117+
// store the request in IndexedDB
118+
const storeRequest = async ({url, method, body, headers, mode, credentials}) => {
119+
const serializedHeaders = serializeHeaders(headers);
120+
121+
try {
122+
// Read the body stream and convert it to text or ArrayBuffer
123+
let storedBody = body;
124+
125+
if(body && body instanceof ReadableStream) {
126+
const clonedBody = body.tee()[0];
127+
storedBody = await new Response(clonedBody).arrayBuffer();
128+
}
129+
130+
const timestamp = Date.now();
131+
const store = await openStore(IDBConfig.stores.requestStore, 'readwrite');
132+
133+
await store.add({
134+
timestamp,
135+
url,
136+
method,
137+
...(storedBody && {body: storedBody}),
138+
headers: serializedHeaders,
139+
mode,
140+
credentials
141+
});
142+
143+
// register a sync event for retrying failed requests if Background Sync is supported
144+
if('sync' in self.registration) {
145+
console.log('register sync for retry request');
146+
await self.registration.sync.register(`retry-request`);
147+
}
148+
}
149+
catch(error) {
150+
console.log('idb error', error);
151+
}
152+
};
153+
154+
// get the names of the caches of the current Service Worker and any outdated ones
155+
const getCacheStorageNames = async () => {
156+
const cacheNames = await caches.keys() || [];
157+
const outdatedCacheNames = cacheNames.filter(name => !name.includes(cacheName));
158+
const latestCacheName = cacheNames.find(name => name.includes(cacheName));
159+
160+
return {latestCacheName, outdatedCacheNames};
161+
};
162+
163+
164+
// update outdated caches with the content of the latest one so new content is served immediately
165+
// when the Service Worker is updated but it can't serve this new content yet on the first navigation or reload
166+
const updateLastCache = async () => {
167+
const {latestCacheName, outdatedCacheNames} = await getCacheStorageNames();
168+
if(!latestCacheName || !outdatedCacheNames?.length) {
169+
return null;
170+
}
171+
172+
const latestCache = await caches.open(latestCacheName);
173+
const latestCacheEntries = (await latestCache?.keys())?.map(c => c.url) || [];
174+
175+
for(const outdatedCacheName of outdatedCacheNames) {
176+
const outdatedCache = await caches.open(outdatedCacheName);
177+
178+
for(const entry of latestCacheEntries) {
179+
const latestCacheResponse = await latestCache.match(entry);
180+
181+
await outdatedCache.put(entry, latestCacheResponse.clone());
182+
}
183+
}
184+
};
185+
186+
// get all requests from IndexedDB that were stored when the app was offline
187+
const getRequests = async () => {
188+
try {
189+
const store = await openStore(IDBConfig.stores.requestStore, 'readwrite');
190+
return await store.getAll();
191+
}
192+
catch(err) {
193+
return err;
194+
}
195+
};
196+
197+
// retry failed requests that were stored in IndexedDB when the app was offline
198+
const retryRequests = async () => {
199+
const reqs = await getRequests();
200+
const requests = reqs.map(({url, method, headers: serializedHeaders, body, mode, credentials}) => {
201+
const headers = new Headers(serializedHeaders);
202+
203+
return fetch(url, {method, headers, body, mode, credentials});
204+
});
205+
206+
const responses = await Promise.allSettled(requests);
207+
const requestStore = await openStore(IDBConfig.stores.requestStore, 'readwrite');
208+
const {keyPath} = IDBConfig.stores.requestStore;
209+
210+
responses.forEach((response, index) => {
211+
const key = reqs[index][keyPath];
212+
213+
// remove the request from IndexedDB if the response was successful
214+
if(response.status === 'fulfilled') {
215+
requestStore.delete(key);
216+
}
217+
else {
218+
console.log(`retrying response with ${keyPath} ${key} failed: ${response.reason}`);
219+
}
220+
});
221+
};
222+
223+
// cache all files and routes when the Service Worker is installed
224+
// add {cache: 'no-cache'} } to all requests to bypass the browser cache so content is always fetched from the server
225+
const installHandler = e => {
226+
e.waitUntil(
227+
caches.open(cacheName)
228+
.then((cache) => Promise.all([
229+
cache.addAll(filesToCache.map(file => new Request(file, {cache: 'no-cache'}))),
230+
createIndexedDB(IDBConfig)
231+
]))
232+
.catch(err => console.error('install error', err))
233+
);
234+
};
235+
236+
// delete any outdated caches when the Service Worker is activated
237+
const activateHandler = e => {
238+
e.waitUntil(
239+
caches.keys()
240+
.then(names => Promise.all(
241+
names
242+
.filter(name => name !== cacheName)
243+
.map(name => caches.delete(name))
244+
))
245+
);
246+
};
247+
248+
// in case the caches response is a redirect, we need to clone it to set its "redirected" property to false
249+
// otherwise the Service Worker will throw an error since this is a security restriction
250+
const cleanRedirect = async (response) => {
251+
const clonedResponse = response.clone();
252+
const {headers, status, statusText} = clonedResponse;
253+
254+
return new Response(clonedResponse.body, {
255+
headers,
256+
status,
257+
statusText,
258+
});
259+
};
260+
261+
// the fetch event handler for the Service Worker that is invoked for each request
262+
const fetchHandler = async e => {
263+
const {request} = e;
264+
265+
e.respondWith(
266+
(async () => {
267+
try {
268+
// store requests to IndexedDB that are eligible for retry when offline and return the offline page
269+
// as response so no error is logged
270+
if(isOffline() && isRequestEligibleForRetry(request)) {
271+
console.log('storing request', request);
272+
await storeRequest(request);
273+
274+
return await caches.match('/offline.html');
275+
}
276+
277+
// try to get the response from the cache
278+
const response = await caches.match(request, {ignoreVary: true, ignoreSearch: true});
279+
if(response) {
280+
return response.redirected ? cleanRedirect(response) : response;
281+
}
282+
283+
// if not in the cache, try to fetch the response from the network
284+
const fetchResponse = await fetch(e.request);
285+
if(fetchResponse) {
286+
return fetchResponse;
287+
}
288+
}
289+
catch(err) {
290+
// a fetch error occurred, serve the offline page since we don't have a cached response
291+
return await caches.match('/offline.html');
292+
}
293+
})()
294+
);
295+
296+
};
297+
298+
299+
// message handler for communication between the main thread and the Service Worker through postMessage
300+
const messageHandler = async ({data}) => {
301+
const {type} = data;
302+
303+
switch(type) {
304+
case 'SKIP_WAITING':
305+
const clients = await self.clients.matchAll({
306+
includeUncontrolled: true,
307+
});
308+
309+
// if the Service Worker is serving 1 client at most, it can be safely skip waiting to update immediately
310+
if(clients.length < 2) {
311+
await self.skipWaiting();
312+
await self.clients.claim();
313+
}
314+
315+
break;
316+
317+
// move the files of the new cache to the old one so when the user navigates to another page or reloads the
318+
// current one, the new content will be served immediately
319+
case 'PREPARE_CACHES_FOR_UPDATE':
320+
await updateLastCache();
321+
322+
break;
323+
324+
// retry any requests that were stored in IndexedDB when the app was offline in browsers that don't
325+
// support Background Sync
326+
case 'retry-requests':
327+
if(!('sync' in self.registration)) {
328+
console.log('retry requests when Background Sync is not supported');
329+
await retryRequests();
330+
}
331+
332+
break;
333+
}
334+
};
335+
336+
const syncHandler = async e => {
337+
console.log('sync event with tag:', e.tag);
338+
339+
const {tag} = e;
340+
341+
switch(tag) {
342+
case 'retry-request':
343+
e.waitUntil(retryRequests());
344+
345+
break;
346+
}
347+
};
348+
349+
self.addEventListener('install', installHandler);
350+
self.addEventListener('activate', activateHandler);
351+
self.addEventListener('fetch', fetchHandler);
352+
self.addEventListener('message', messageHandler);
353+
self.addEventListener('sync', syncHandler);

0 commit comments

Comments
 (0)