-
Notifications
You must be signed in to change notification settings - Fork 0
/
sw.js
403 lines (340 loc) · 13.5 KB
/
sw.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
"use strict";
const OFFLINE_DATA_FILE = "offline.js";
const CACHE_NAME_PREFIX = "c2offline";
const BROADCASTCHANNEL_NAME = "offline";
const CONSOLE_PREFIX = "[SW] ";
const LAZYLOAD_KEYNAME = "";
// Create a BroadcastChannel if supported.
const broadcastChannel = (typeof BroadcastChannel === "undefined" ? null : new BroadcastChannel(BROADCASTCHANNEL_NAME));
//////////////////////////////////////
// Utility methods
function PostBroadcastMessage(o)
{
if (!broadcastChannel)
return; // not supported
// Impose artificial (and arbitrary!) delay of 3 seconds to make sure client is listening by the time the message is sent.
// Note we could remove the delay on some messages, but then we create a race condition where sometimes messages can arrive
// in the wrong order (e.g. "update ready" arrives before "started downloading update"). So to keep the consistent ordering,
// delay all messages by the same amount.
setTimeout(() => broadcastChannel.postMessage(o), 3000);
};
function Broadcast(type)
{
PostBroadcastMessage({
"type": type
});
};
function BroadcastDownloadingUpdate(version)
{
PostBroadcastMessage({
"type": "downloading-update",
"version": version
});
}
function BroadcastUpdateReady(version)
{
PostBroadcastMessage({
"type": "update-ready",
"version": version
});
}
function IsUrlInLazyLoadList(url, lazyLoadList)
{
if (!lazyLoadList)
return false; // presumably lazy load list failed to load
try {
for (const lazyLoadRegex of lazyLoadList)
{
if (new RegExp(lazyLoadRegex).test(url))
return true;
}
}
catch (err)
{
console.error(CONSOLE_PREFIX + "Error matching in lazy-load list: ", err);
}
return false;
};
function WriteLazyLoadListToStorage(lazyLoadList)
{
if (typeof localforage === "undefined")
return Promise.resolve(); // bypass if localforage not imported
else
return localforage.setItem(LAZYLOAD_KEYNAME, lazyLoadList)
};
function ReadLazyLoadListFromStorage()
{
if (typeof localforage === "undefined")
return Promise.resolve([]); // bypass if localforage not imported
else
return localforage.getItem(LAZYLOAD_KEYNAME);
};
function GetCacheBaseName()
{
// Include the scope to avoid name collisions with any other SWs on the same origin.
// e.g. "c2offline-https://example.com/foo/" (won't collide with anything under bar/)
return CACHE_NAME_PREFIX + "-" + self.registration.scope;
};
function GetCacheVersionName(version)
{
// Append the version number to the cache name.
// e.g. "c2offline-https://example.com/foo/-v2"
return GetCacheBaseName() + "-v" + version;
};
// Return caches.keys() filtered down to just caches we're interested in (with the right base name).
// This filters out caches from unrelated scopes.
async function GetAvailableCacheNames()
{
const cacheNames = await caches.keys();
const cacheBaseName = GetCacheBaseName();
return cacheNames.filter(n => n.startsWith(cacheBaseName));
};
// Identify if an update is pending, which is the case when we have 2 or more available caches.
// One must be an update that is waiting, since the next navigate that does an upgrade will
// delete all the old caches leaving just one currently-in-use cache.
async function IsUpdatePending()
{
const availableCacheNames = await GetAvailableCacheNames();
return (availableCacheNames.length >= 2);
};
// Automatically deduce the main page URL (e.g. index.html or main.aspx) from the available browser windows.
// This prevents having to hard-code an index page in the file list, implicitly caching it like AppCache did.
async function GetMainPageUrl()
{
const allClients = await clients.matchAll({
includeUncontrolled: true,
type: "window"
});
for (const c of allClients)
{
// Parse off the scope from the full client URL, e.g. https://example.com/index.html -> index.html
let url = c.url;
if (url.startsWith(self.registration.scope))
url = url.substring(self.registration.scope.length);
if (url && url !== "/") // ./ is also implicitly cached so don't bother returning that
{
// If the URL is solely a search string, prefix it with / to ensure it caches correctly.
// e.g. https://example.com/?foo=bar needs to cache as /?foo=bar, not just ?foo=bar.
if (url.startsWith("?"))
url = "/" + url;
return url;
}
}
return ""; // no main page URL could be identified
};
// Hack to fetch optionally bypassing HTTP cache until fetch cache options are supported in Chrome (crbug.com/453190)
function fetchWithBypass(request, bypassCache)
{
if (typeof request === "string")
request = new Request(request);
if (bypassCache)
{
// bypass enabled: add a random search parameter to avoid getting a stale HTTP cache result
const url = new URL(request.url);
url.search += Math.floor(Math.random() * 1000000);
return fetch(url, {
headers: request.headers,
mode: request.mode,
credentials: request.credentials,
redirect: request.redirect,
cache: "no-store"
});
}
else
{
// bypass disabled: perform normal fetch which is allowed to return from HTTP cache
return fetch(request);
}
};
// Effectively a cache.addAll() that only creates the cache on all requests being successful (as a weak attempt at making it atomic)
// and can optionally cache-bypass with fetchWithBypass in every request
async function CreateCacheFromFileList(cacheName, fileList, bypassCache)
{
// Kick off all requests and wait for them all to complete
const responses = await Promise.all(fileList.map(url => fetchWithBypass(url, bypassCache)));
// Check if any request failed. If so don't move on to opening the cache.
// This makes sure we only open a cache if all requests succeeded.
let allOk = true;
for (const response of responses)
{
if (!response.ok)
{
allOk = false;
console.error(CONSOLE_PREFIX + "Error fetching '" + response.url + "' (" + response.status + " " + response.statusText + ")");
}
}
if (!allOk)
throw new Error("not all resources were fetched successfully");
// Can now assume all responses are OK. Open a cache and write all responses there.
// TODO: ideally we can do this transactionally to ensure a complete cache is written as one atomic operation.
// This needs either new transactional features in the spec, or at the very least a way to rename a cache
// (so we can write to a temporary name that won't be returned by GetAvailableCacheNames() and then rename it when ready).
const cache = await caches.open(cacheName);
try {
return await Promise.all(responses.map(
(response, i) => cache.put(fileList[i], response)
));
}
catch (err)
{
// Not sure why cache.put() would fail (maybe if storage quota exceeded?) but in case it does,
// clean up the cache to try to avoid leaving behind an incomplete cache.
console.error(CONSOLE_PREFIX + "Error writing cache entries: ", err);
caches.delete(cacheName);
throw err;
}
};
async function UpdateCheck(isFirst)
{
try {
// Always bypass cache when requesting offline.js to make sure we find out about new versions.
const response = await fetchWithBypass(OFFLINE_DATA_FILE, true);
if (!response.ok)
throw new Error(OFFLINE_DATA_FILE + " responded with " + response.status + " " + response.statusText);
const data = await response.json();
const version = data.version;
const fileList = data.fileList;
const lazyLoadList = data.lazyLoad;
const currentCacheName = GetCacheVersionName(version);
const cacheExists = await caches.has(currentCacheName);
// Don't recache if there is already a cache that exists for this version. Assume it is complete.
if (cacheExists)
{
// Log whether we are up-to-date or pending an update.
const isUpdatePending = await IsUpdatePending();
if (isUpdatePending)
{
console.log(CONSOLE_PREFIX + "Update pending");
Broadcast("update-pending");
}
else
{
console.log(CONSOLE_PREFIX + "Up to date");
Broadcast("up-to-date");
}
return;
}
// Implicitly add the main page URL to the file list, e.g. "index.html", so we don't have to assume a specific name.
const mainPageUrl = await GetMainPageUrl();
// Prepend the main page URL to the file list if we found one and it is not already in the list.
// Also make sure we request the base / which should serve the main page.
fileList.unshift("./");
if (mainPageUrl && fileList.indexOf(mainPageUrl) === -1)
fileList.unshift(mainPageUrl);
console.log(CONSOLE_PREFIX + "Caching " + fileList.length + " files for offline use");
if (isFirst)
Broadcast("downloading");
else
BroadcastDownloadingUpdate(version);
// Note we don't bypass the cache on the first update check. This is because SW installation and the following
// update check caching will race with the normal page load requests. For any normal loading fetches that have already
// completed or are in-flight, it is pointless and wasteful to cache-bust the request for offline caching, since that
// forces a second network request to be issued when a response from the browser HTTP cache would be fine.
if (lazyLoadList)
await WriteLazyLoadListToStorage(lazyLoadList); // dump lazy load list to local storage#
await CreateCacheFromFileList(currentCacheName, fileList, !isFirst);
const isUpdatePending = await IsUpdatePending();
if (isUpdatePending)
{
console.log(CONSOLE_PREFIX + "All resources saved, update ready");
BroadcastUpdateReady(version);
}
else
{
console.log(CONSOLE_PREFIX + "All resources saved, offline support ready");
Broadcast("offline-ready");
}
}
catch (err)
{
// Update check fetches fail when we're offline, but in case there's any other kind of problem with it, log a warning.
console.warn(CONSOLE_PREFIX + "Update check failed: ", err);
}
};
self.addEventListener("install", event =>
{
// On install kick off an update check to cache files on first use.
// If it fails we can still complete the install event and leave the SW running, we'll just
// retry on the next navigate.
event.waitUntil(
UpdateCheck(true) // first update
.catch(() => null)
);
});
async function GetCacheNameToUse(availableCacheNames, doUpdateCheck)
{
// Prefer the oldest cache available. This avoids mixed-version responses by ensuring that if a new cache
// is created and filled due to an update check while the page is running, we keep returning resources
// from the original (oldest) cache only.
if (availableCacheNames.length === 1 || !doUpdateCheck)
return availableCacheNames[0];
// We are making a navigate request with more than one cache available. Check if we can expire any old ones.
const allClients = await clients.matchAll();
// If there are other clients open, don't expire anything yet. We don't want to delete any caches they
// might be using, which could cause mixed-version responses.
if (allClients.length > 1)
return availableCacheNames[0];
// Identify newest cache to use. Delete all the others.
const latestCacheName = availableCacheNames[availableCacheNames.length - 1];
console.log(CONSOLE_PREFIX + "Updating to new version");
await Promise.all(
availableCacheNames.slice(0, -1)
.map(c => caches.delete(c))
);
return latestCacheName;
};
async function HandleFetch(event, doUpdateCheck)
{
const availableCacheNames = await GetAvailableCacheNames();
// No caches available: go to network
if (!availableCacheNames.length)
return fetch(event.request);
const useCacheName = await GetCacheNameToUse(availableCacheNames, doUpdateCheck);
const cache = await caches.open(useCacheName);
const cachedResponse = await cache.match(event.request);
if (cachedResponse)
return cachedResponse; // use cached response
// We need to check if this request is to be lazy-cached. Send the request and load the lazy-load list
// from storage simultaneously.
const result = await Promise.all([fetch(event.request), ReadLazyLoadListFromStorage()]);
const fetchResponse = result[0];
const lazyLoadList = result[1];
if (IsUrlInLazyLoadList(event.request.url, lazyLoadList))
{
// Handle failure writing to the cache. This can happen if the storage quota is exceeded, which is particularly
// likely in Safari 11.1, which appears to have very tight storage limits. Make sure even in the event of an error
// we continue to return the response from the fetch.
try {
// Note clone response since we also respond with it
await cache.put(event.request, fetchResponse.clone());
}
catch (err)
{
console.warn(CONSOLE_PREFIX + "Error caching '" + event.request.url + "': ", err);
}
}
return fetchResponse;
};
self.addEventListener("fetch", event =>
{
/** NOTE (iain)
* This check is to prevent a bug with XMLHttpRequest where if its
* proxied with "FetchEvent.prototype.respondWith" no upload progress
* events are triggered. By returning we allow the default action to
* occur instead. Currently all cross-origin requests fall back to default.
*/
if (new URL(event.request.url).origin !== location.origin)
return;
// Check for an update on navigate requests
const doUpdateCheck = (event.request.mode === "navigate");
const responsePromise = HandleFetch(event, doUpdateCheck);
if (doUpdateCheck)
{
// allow the main request to complete, then check for updates
event.waitUntil(
responsePromise
.then(() => UpdateCheck(false)) // not first check
);
}
event.respondWith(responsePromise);
});