From 08186a9c92d0adad2f5d1592aa5e4229b4babb5e Mon Sep 17 00:00:00 2001 From: Eduard Urbach Date: Thu, 18 Apr 2019 23:44:45 +0900 Subject: [PATCH] Simplified service worker --- scripts/Application.ts | 1 + scripts/ServiceWorker/ServiceWorker.ts | 709 ++++++++++++------------- 2 files changed, 344 insertions(+), 366 deletions(-) diff --git a/scripts/Application.ts b/scripts/Application.ts index cd8779a3..ef037b1a 100644 --- a/scripts/Application.ts +++ b/scripts/Application.ts @@ -49,6 +49,7 @@ export default class Application { return new Promise((resolve, reject) => { let request = new XMLHttpRequest() + request.timeout = 20000 request.onerror = () => reject(new Error("You are either offline or the requested page doesn't exist.")) request.ontimeout = () => reject(new Error("The page took too much time to respond.")) request.onload = () => { diff --git a/scripts/ServiceWorker/ServiceWorker.ts b/scripts/ServiceWorker/ServiceWorker.ts index d6b00279..34d61474 100644 --- a/scripts/ServiceWorker/ServiceWorker.ts +++ b/scripts/ServiceWorker/ServiceWorker.ts @@ -32,369 +32,6 @@ // E-Tags that we served for a given URL // const ETAGS = new Map() -// MyServiceWorker is the process that controls all the tabs in a browser. -class MyServiceWorker { - cache: MyCache - reloads: Map> - excludeCache: Set - - constructor() { - this.cache = new MyCache("v-6") - this.reloads = new Map>() - - // When these patterns are matched for the request URL, we exclude them from being - // served cache-first and instead serve them via a network request. - // Note that the service worker URL is automatically excluded from fetch events - // and therefore doesn't need to be added here. - this.excludeCache = new Set([ - // API requests - "/api/", - - // PayPal stuff - "/paypal/", - - // List imports - "/import/", - - // Infinite scrolling - "/from/", - - // Chrome extension - "chrome-extension", - - // Authorization paths /auth/ and /logout are not listed here because they are handled in a special way. - ]) - - self.addEventListener("install", (evt: InstallEvent) => evt.waitUntil(this.onInstall(evt))) - self.addEventListener("activate", (evt: any) => evt.waitUntil(this.onActivate(evt))) - self.addEventListener("fetch", (evt: FetchEvent) => evt.waitUntil(this.onRequest(evt))) - self.addEventListener("message", (evt: any) => evt.waitUntil(this.onMessage(evt))) - self.addEventListener("push", (evt: PushEvent) => evt.waitUntil(this.onPush(evt))) - self.addEventListener("pushsubscriptionchange", (evt: any) => evt.waitUntil(this.onPushSubscriptionChange(evt))) - self.addEventListener("notificationclick", (evt: NotificationEvent) => evt.waitUntil(this.onNotificationClick(evt))) - } - - async onInstall(evt: InstallEvent) { - console.log("service worker install") - - await self.skipWaiting() - await this.installCache() - } - - onActivate(evt: any) { - console.log("service worker activate") - - // Only keep current version of the cache and delete old caches - let cacheWhitelist = [this.cache.version] - - let deleteOldCache = caches.keys().then(keyList => { - return Promise.all(keyList.map(key => { - if(cacheWhitelist.indexOf(key) === -1) { - return caches.delete(key) - } - })) - }) - - // Immediate claim helps us gain control over a new client immediately - let immediateClaim = self.clients.claim() - - return Promise.all([ - deleteOldCache, - immediateClaim - ]) - } - - // onRequest intercepts all browser requests. - // Simply returning, without calling evt.respondWith(), - // will let the browser deal with the request normally. - async onRequest(evt: FetchEvent) { - let request = evt.request as Request - - // If it's not a GET request, fetch it normally. - // Let the browser handle XHR upload requests via POST, - // so that we can receive upload progress events. - if(request.method !== "GET") { - return - } - - // DevTools opening will trigger these "only-if-cached" requests. - // https://bugs.chromium.org/p/chromium/issues/detail?id=823392 - if((request.cache as string) === "only-if-cached" && request.mode !== "same-origin") { - return - } - - // Video files are always loaded over the network. - // We are defaulting to the normal browser handler here - // so we can see the HTTP 206 partial responses in DevTools - // and it also seems to have slightly smoother video playback. - if(request.url.includes("/videos/")) { - return - } - - return evt.respondWith(fetch(request)) - - // // Exclude certain URLs from being cached. - // for(let pattern of this.excludeCache.keys()) { - // if(request.url.includes(pattern)) { - // return - // } - // } - - // // If the request has cache set to "force-cache", return a cache-only response. - // // This is used in reloads to avoid generating a 2nd request after a cache refresh. - // if(request.headers.get("X-Force-Cache") === "true") { - // return evt.respondWith(this.cache.serve(request)) - // } - - // // -------------------------------------------------------------------------------- - // // Cross-origin requests. - // // -------------------------------------------------------------------------------- - - // // These hosts don't support CORS. Always load via network. - // if(request.url.startsWith("https://img.youtube.com/")) { - // return - // } - - // // Use CORS for cross-origin requests. - // if(!request.url.startsWith("https://notify.moe/") && !request.url.startsWith("https://beta.notify.moe/")) { - // request = new Request(request.url, { - // credentials: "omit", - // mode: "cors" - // }) - // } else { - // // let relativePath = trimPrefix(request.url, "https://notify.moe") - // // relativePath = trimPrefix(relativePath, "https://beta.notify.moe") - // // console.log(relativePath) - // } - - // // -------------------------------------------------------------------------------- - // // Network refresh. - // // -------------------------------------------------------------------------------- - - // // Save response in cache. - // let saveResponseInCache = (response: Response) => { - // let contentType = response.headers.get("Content-Type") - - // // Don't cache anything other than text, styles, scripts, fonts and images. - // if(!contentType.includes("text/") && !contentType.includes("application/javascript") && !contentType.includes("image/") && !contentType.includes("font/")) { - // return response - // } - - // // Save response in cache. - // if(response.ok) { - // let clone = response.clone() - // this.cache.store(request, clone) - // } - - // return response - // } - - // let onResponse = (response: Response | null) => { - // return response - // } - - // // Refresh resource via a network request. - // let refresh = fetch(request).then(saveResponseInCache) - - // // -------------------------------------------------------------------------------- - // // Final response. - // // -------------------------------------------------------------------------------- - - // // Clear cache on authentication and fetch it normally. - // if(request.url.includes("/auth/") || request.url.includes("/logout")) { - // return evt.respondWith(this.cache.clear().then(() => refresh)) - // } - - // // If the request has cache set to "no-cache", - // // return the network-only response even if it fails. - // if(request.headers.get("X-No-Cache") === "true") { - // return evt.respondWith(refresh) - // } - - // // Styles and scripts will be served via network first and fallback to cache. - // if(request.url.endsWith("/styles") || request.url.endsWith("/scripts")) { - // evt.respondWith(this.networkFirst(request, refresh, onResponse)) - // return refresh - // } - - // // -------------------------------------------------------------------------------- - // // Default behavior for most requests. - // // -------------------------------------------------------------------------------- - - // // // Respond via cache first. - // // evt.respondWith(this.cacheFirst(request, refresh, onResponse)) - // // return refresh - - // // Serve via network first and fallback to cache. - // evt.respondWith(this.networkFirst(request, refresh, onResponse)) - // return refresh - } - - // onMessage is called when the service worker receives a message from a client (browser tab). - async onMessage(evt: ServiceWorkerMessageEvent) { - let message = JSON.parse(evt.data) - let clientId = (evt.source as any).id - let client = await MyClient.get(clientId) - - client.onMessage(message) - } - - // onPush is called on push events and requires the payload to contain JSON information about the notification. - onPush(evt: PushEvent) { - var payload = evt.data ? evt.data.json() : {} - - // Notify all clients about the new notification so they can update their notification counter - this.broadcast({ - type: "new notification" - }) - - // Display the notification - return self.registration.showNotification(payload.title, { - body: payload.message, - icon: payload.icon, - data: payload.link, - badge: "https://media.notify.moe/images/brand/64.png" - }) - } - - onPushSubscriptionChange(evt: any) { - return self.registration.pushManager.subscribe(evt.oldSubscription.options) - .then(async subscription => { - console.log("send subscription to server...") - - let rawKey = subscription.getKey("p256dh") - let key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : "" - - let rawSecret = subscription.getKey("auth") - let secret = rawSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawSecret))) : "" - - let endpoint = subscription.endpoint - - let pushSubscription = { - endpoint, - p256dh: key, - auth: secret, - platform: navigator.platform, - userAgent: navigator.userAgent, - screen: { - width: window.screen.width, - height: window.screen.height - } - } - - let response = await fetch("/api/me", {credentials: "same-origin"}) - let user = await response.json() - - return fetch("/api/pushsubscriptions/" + user.id + "/add", { - method: "POST", - credentials: "same-origin", - body: JSON.stringify(pushSubscription) - }) - }) - } - - // onNotificationClick is called when the user clicks on a notification. - onNotificationClick(evt: NotificationEvent) { - let notification = evt.notification - notification.close() - - return self.clients.matchAll().then(function(clientList) { - // If we have a link, use that link to open a new window. - let url = notification.data - - if(url) { - return self.clients.openWindow(url) - } - - // If there is at least one client, focus it. - if(clientList.length > 0) { - return (clientList[0] as WindowClient).focus() - } - - // Otherwise open a new window - return self.clients.openWindow("https://notify.moe") - }) - } - - // Broadcast sends a message to all clients (open tabs etc.) - broadcast(msg: object) { - const msgText = JSON.stringify(msg) - - self.clients.matchAll().then(function(clientList) { - for(let client of clientList) { - client.postMessage(msgText) - } - }) - } - - // installCache is called when the service worker is installed for the first time. - async installCache() { - let urls = [ - "/", - "/scripts", - "/styles", - "/manifest.json", - "https://media.notify.moe/images/elements/noise-strong.png", - ] - - let promises = [] - - for(let url of urls) { - let request = new Request(url, { - credentials: "same-origin", - mode: "cors" - }) - - let promise = fetch(request).then(response => this.cache.store(request, response)) - promises.push(promise) - } - - return Promise.all(promises) - } - - // Serve network first. - // Fall back to cache. - async networkFirst(request: Request, network: Promise, onResponse: (r: Response) => Response): Promise { - let response: Response | null - - try { - response = await network - // console.log("Network HIT:", request.url) - } catch(error) { - // console.log("Network MISS:", request.url, error) - - try { - response = await this.cache.serve(request) - } catch(error) { - return Promise.reject(error) - } - } - - return onResponse(response) - } - - // Serve cache first. - // Fall back to network. - async cacheFirst(request: Request, network: Promise, onResponse: (r: Response) => Response): Promise { - let response: Response | null - - try { - response = await this.cache.serve(request) - // console.log("Cache HIT:", request.url) - } catch(error) { - // console.log("Cache MISS:", request.url, error) - - try { - response = await network - } catch(error) { - return Promise.reject(error) - } - } - - return onResponse(response) - } -} - // MyCache is the cache used by the service worker. class MyCache { version: string @@ -429,6 +66,340 @@ class MyCache { } } +// Globals +let cache = new MyCache("v-7") +let reloads = new Map>() + +// When these patterns are matched for the request URL, we exclude them from being +// served cache-first and instead serve them via a network request. +// Note that the service worker URL is automatically excluded from fetch events +// and therefore doesn't need to be added here. +let excludeCache = new Set([ + "/api/", // API requests + "/paypal/", // PayPal stuff + "/import/", // List imports + "/from/", // Infinite scrolling + "chrome-extension", // Chrome extension + + // Authorization paths /auth/ and /logout are not listed here because they are handled in a special way. +]) + +// onInstall +async function onInstall(evt: InstallEvent) { + console.log("service worker install") + + await self.skipWaiting() + await installCache() +} + +// onActivate +function onActivate(evt: any) { + console.log("service worker activate") + + // Only keep current version of the cache and delete old caches + let cacheWhitelist = [cache.version] + + let deleteOldCache = caches.keys().then(keyList => { + return Promise.all(keyList.map(key => { + if(cacheWhitelist.indexOf(key) === -1) { + return caches.delete(key) + } + })) + }) + + // Immediate claim helps us gain control over a new client immediately + let immediateClaim = self.clients.claim() + + return Promise.all([ + deleteOldCache, + immediateClaim + ]) +} + +// onRequest intercepts all browser requests. +// Simply returning, without calling evt.respondWith(), +// will let the browser deal with the request normally. +async function onRequest(evt: FetchEvent) { + let request = evt.request as Request + + // If it's not a GET request, fetch it normally. + // Let the browser handle XHR upload requests via POST, + // so that we can receive upload progress events. + if(request.method !== "GET") { + return + } + + // Video files are always loaded over the network. + // We are defaulting to the normal browser handler here + // so we can see the HTTP 206 partial responses in DevTools + // and it also seems to have slightly smoother video playback. + if(request.url.includes("/videos/")) { + return + } + + return //evt.respondWith(fetch(request)) + + // // Exclude certain URLs from being cached. + // for(let pattern of this.excludeCache.keys()) { + // if(request.url.includes(pattern)) { + // return + // } + // } + + // // If the request has cache set to "force-cache", return a cache-only response. + // // This is used in reloads to avoid generating a 2nd request after a cache refresh. + // if(request.headers.get("X-Force-Cache") === "true") { + // return evt.respondWith(this.cache.serve(request)) + // } + + // // -------------------------------------------------------------------------------- + // // Cross-origin requests. + // // -------------------------------------------------------------------------------- + + // // These hosts don't support CORS. Always load via network. + // if(request.url.startsWith("https://img.youtube.com/")) { + // return + // } + + // // Use CORS for cross-origin requests. + // if(!request.url.startsWith("https://notify.moe/") && !request.url.startsWith("https://beta.notify.moe/")) { + // request = new Request(request.url, { + // credentials: "omit", + // mode: "cors" + // }) + // } else { + // // let relativePath = trimPrefix(request.url, "https://notify.moe") + // // relativePath = trimPrefix(relativePath, "https://beta.notify.moe") + // // console.log(relativePath) + // } + + // // -------------------------------------------------------------------------------- + // // Network refresh. + // // -------------------------------------------------------------------------------- + + // // Save response in cache. + // let saveResponseInCache = (response: Response) => { + // let contentType = response.headers.get("Content-Type") + + // // Don't cache anything other than text, styles, scripts, fonts and images. + // if(!contentType.includes("text/") && !contentType.includes("application/javascript") && !contentType.includes("image/") && !contentType.includes("font/")) { + // return response + // } + + // // Save response in cache. + // if(response.ok) { + // let clone = response.clone() + // this.cache.store(request, clone) + // } + + // return response + // } + + // let onResponse = (response: Response | null) => { + // return response + // } + + // // Refresh resource via a network request. + // let refresh = fetch(request).then(saveResponseInCache) + + // // -------------------------------------------------------------------------------- + // // Final response. + // // -------------------------------------------------------------------------------- + + // // Clear cache on authentication and fetch it normally. + // if(request.url.includes("/auth/") || request.url.includes("/logout")) { + // return evt.respondWith(this.cache.clear().then(() => refresh)) + // } + + // // If the request has cache set to "no-cache", + // // return the network-only response even if it fails. + // if(request.headers.get("X-No-Cache") === "true") { + // return evt.respondWith(refresh) + // } + + // // Styles and scripts will be served via network first and fallback to cache. + // if(request.url.endsWith("/styles") || request.url.endsWith("/scripts")) { + // evt.respondWith(this.networkFirst(request, refresh, onResponse)) + // return refresh + // } + + // // -------------------------------------------------------------------------------- + // // Default behavior for most requests. + // // -------------------------------------------------------------------------------- + + // // // Respond via cache first. + // // evt.respondWith(this.cacheFirst(request, refresh, onResponse)) + // // return refresh + + // // Serve via network first and fallback to cache. + // evt.respondWith(this.networkFirst(request, refresh, onResponse)) + // return refresh +} + +// onMessage is called when the service worker receives a message from a client (browser tab). +async function onMessage(evt: ServiceWorkerMessageEvent) { + let message = JSON.parse(evt.data) + let clientId = (evt.source as any).id + let client = await MyClient.get(clientId) + + client.onMessage(message) +} + +// onPush is called on push events and requires the payload to contain JSON information about the notification. +function onPush(evt: PushEvent) { + var payload = evt.data ? evt.data.json() : {} + + // Notify all clients about the new notification so they can update their notification counter + broadcast({ + type: "new notification" + }) + + // Display the notification + return self.registration.showNotification(payload.title, { + body: payload.message, + icon: payload.icon, + data: payload.link, + badge: "https://media.notify.moe/images/brand/256.png" + }) +} + +function onPushSubscriptionChange(evt: any) { + return self.registration.pushManager.subscribe(evt.oldSubscription.options) + .then(async subscription => { + console.log("send subscription to server...") + + let rawKey = subscription.getKey("p256dh") + let key = rawKey ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawKey))) : "" + + let rawSecret = subscription.getKey("auth") + let secret = rawSecret ? btoa(String.fromCharCode.apply(null, new Uint8Array(rawSecret))) : "" + + let endpoint = subscription.endpoint + + let pushSubscription = { + endpoint, + p256dh: key, + auth: secret, + platform: navigator.platform, + userAgent: navigator.userAgent, + screen: { + width: window.screen.width, + height: window.screen.height + } + } + + let response = await fetch("/api/me", {credentials: "same-origin"}) + let user = await response.json() + + return fetch("/api/pushsubscriptions/" + user.id + "/add", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(pushSubscription) + }) + }) +} + +// onNotificationClick is called when the user clicks on a notification. +function onNotificationClick(evt: NotificationEvent) { + let notification = evt.notification + notification.close() + + return self.clients.matchAll().then(function(clientList) { + // If we have a link, use that link to open a new window. + let url = notification.data + + if(url) { + return self.clients.openWindow(url) + } + + // If there is at least one client, focus it. + if(clientList.length > 0) { + return (clientList[0] as WindowClient).focus() + } + + // Otherwise open a new window + return self.clients.openWindow("https://notify.moe") + }) +} + +// Broadcast sends a message to all clients (open tabs etc.) +function broadcast(msg: object) { + const msgText = JSON.stringify(msg) + + self.clients.matchAll().then(function(clientList) { + for(let client of clientList) { + client.postMessage(msgText) + } + }) +} + +// installCache is called when the service worker is installed for the first time. +async function installCache() { + let urls = [ + "/", + "/scripts", + "/styles", + "/manifest.json", + "https://media.notify.moe/images/elements/noise-strong.png", + ] + + let promises = [] + + for(let url of urls) { + let request = new Request(url, { + credentials: "same-origin", + mode: "cors" + }) + + let promise = fetch(request).then(response => cache.store(request, response)) + promises.push(promise) + } + + return Promise.all(promises) +} + +// Serve network first. +// Fall back to cache. +async function networkFirst(request: Request, network: Promise, onResponse: (r: Response) => Response): Promise { + let response: Response | null + + try { + response = await network + // console.log("Network HIT:", request.url) + } catch(error) { + // console.log("Network MISS:", request.url, error) + + try { + response = await cache.serve(request) + } catch(error) { + return Promise.reject(error) + } + } + + return onResponse(response) +} + +// Serve cache first. +// Fall back to network. +async function cacheFirst(request: Request, network: Promise, onResponse: (r: Response) => Response): Promise { + let response: Response | null + + try { + response = await cache.serve(request) + // console.log("Cache HIT:", request.url) + } catch(error) { + // console.log("Cache MISS:", request.url, error) + + try { + response = await network + } catch(error) { + return Promise.reject(error) + } + } + + return onResponse(response) +} + // MyClient represents a single tab in the browser. class MyClient { // MyClient.idToClient is a Map of clients @@ -462,7 +433,7 @@ class MyClient { case "broadcast": message.type = message.realType delete message.realType - serviceWorker.broadcast(message) + broadcast(message) break } } @@ -488,5 +459,11 @@ function trimPrefix(text, prefix) { return text } -// Initialize the service worker -const serviceWorker = new MyServiceWorker() +// Register event listeners +self.addEventListener("install", (evt: InstallEvent) => evt.waitUntil(onInstall(evt))) +self.addEventListener("activate", (evt: any) => evt.waitUntil(onActivate(evt))) +self.addEventListener("fetch", (evt: FetchEvent) => evt.waitUntil(onRequest(evt))) +self.addEventListener("message", (evt: any) => evt.waitUntil(onMessage(evt))) +self.addEventListener("push", (evt: PushEvent) => evt.waitUntil(onPush(evt))) +self.addEventListener("pushsubscriptionchange", (evt: any) => evt.waitUntil(onPushSubscriptionChange(evt))) +self.addEventListener("notificationclick", (evt: NotificationEvent) => evt.waitUntil(onNotificationClick(evt)))