diff --git a/scripts/AnimeNotifier.ts b/scripts/AnimeNotifier.ts index bbdce8fe..e60b3b3a 100644 --- a/scripts/AnimeNotifier.ts +++ b/scripts/AnimeNotifier.ts @@ -250,6 +250,10 @@ export class AnimeNotifier { } break + + case "reload page": + location.reload(true) + break } } diff --git a/sw/service-worker.ts b/sw/service-worker.ts index 2f4bfafb..441ea201 100644 --- a/sw/service-worker.ts +++ b/sw/service-worker.ts @@ -1,6 +1,5 @@ // pack:ignore -const CACHE = "v-3" const RELOADS = new Map>() const ETAGS = new Map() const CACHEREFRESH = new Map>() @@ -18,239 +17,257 @@ const EXCLUDECACHE = new Set([ "/from/", // Chrome extension - "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) => { - console.log("service worker install") +class MyCache { + version: string - evt.waitUntil( - (self as any).skipWaiting().then(() => { - return installCache() + constructor(version: string) { + this.version = version + } + + store(request: RequestInfo, response: Response) { + return caches.open(this.version).then(cache => { + return cache.put(request, response) }) - ) -}) + } +} -self.addEventListener("activate", (evt: any) => { - console.log("service worker activate") +class MyServiceWorker { + cache: MyCache + currentCSP: string - // Delete old cache - let cacheWhitelist = [CACHE] + constructor() { + this.cache = new MyCache("v-3") + this.currentCSP = "" + + 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))) + } - let deleteOldCache = caches.keys().then(keyList => { - return Promise.all(keyList.map(key => { - if(cacheWhitelist.indexOf(key) === -1) { - return caches.delete(key) - } - })) - }) + onInstall(evt: InstallEvent) { + console.log("service worker install") + + return (self as any).skipWaiting().then(() => { + return this.installCache() + }) + } - let immediateClaim = (self as any).clients.claim() - - // Immediate claim - evt.waitUntil( - Promise.all([ + 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 as any).clients.claim() + + return Promise.all([ deleteOldCache, immediateClaim ]) - ) -}) + } -// controlling service worker -self.addEventListener("message", (evt: any) => { - let message = JSON.parse(evt.data) + onRequest(evt: FetchEvent) { + let request = evt.request as Request - let url = message.url - let refresh = RELOADS.get(url) - let servedETag = ETAGS.get(url) + // console.log("fetch:", request.url) - // If the user requests a sub-page we should prefetch the full page, too. - if(url.includes("/_/")) { - let fullPage = new Request(url.replace("/_/", "/")) + // If it's not a GET request, fetch it normally + if(request.method !== "GET") { + return evt.respondWith(fetch(request)) + } - fetch(fullPage, { - credentials: "same-origin" - }) - .then(response => { + // Clear cache on authentication and fetch it normally + if(request.url.includes("/auth/") || request.url.includes("/logout")) { + return caches.delete(this.cache.version).then(() => evt.respondWith(fetch(request))) + } + + // Exclude certain URLs from being cached + for(let pattern of EXCLUDECACHE.keys()) { + if(request.url.includes(pattern)) { + return evt.respondWith(fetch(request)) + } + } + + // If the request included the header "X-CacheOnly", 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-CacheOnly") === "true") { + return this.fromCache(request) + } + + // Start fetching the request + let refresh = fetch(request).then(response => { + let clone = response.clone() + // Save the new version of the resource in the cache - let cacheRefresh = caches.open(CACHE).then(cache => { - return cache.put(fullPage, response) + let cacheRefresh = this.cache.store(request, clone) + + CACHEREFRESH.set(request.url, cacheRefresh) + + return response + }) + + // Save in map + RELOADS.set(request.url, refresh) + + // Forced reload + let servedETag = undefined + + let onResponse = response => { + servedETag = response.headers.get("ETag") + ETAGS.set(request.url, servedETag) + return response + } + + if(request.headers.get("X-Reload") === "true") { + return evt.respondWith(refresh.then(onResponse)) + } + + // Try to serve cache first and fall back to network response + let networkOrCache = this.fromCache(request).then(onResponse).catch(error => { + // console.log("Cache MISS:", request.url) + return refresh + }) + + return evt.respondWith(networkOrCache) + } + + onMessage(evt: any) { + let message = JSON.parse(evt.data) + + let url = message.url + let refresh = RELOADS.get(url) + let servedETag = ETAGS.get(url) + + // If the user requests a sub-page we should prefetch the full page, too. + if(url.includes("/_/")) { + let fullPage = new Request(url.replace("/_/", "/")) + + let fullPageRefresh = fetch(fullPage, { + credentials: "same-origin" + }).then(response => { + // Save the new version of the resource in the cache + let cacheRefresh = caches.open(this.cache.version).then(cache => { + return cache.put(fullPage, response) + }) + + CACHEREFRESH.set(fullPage.url, cacheRefresh) + return response }) - CACHEREFRESH.set(fullPage.url, cacheRefresh) - return cacheRefresh - }) - } - - if(!refresh || !servedETag) { - return - } + // Save in map + RELOADS.set(fullPage.url, fullPageRefresh) + } - evt.waitUntil( - refresh.then((response: Response) => { + if(!refresh || !servedETag) { + return Promise.resolve() + } + + return refresh.then((response: Response) => { // If the fresh copy was used to serve the request instead of the cache, // we don"t need to tell the client to do a refresh. if(response.bodyUsed) { return } - + + // Get ETag let eTag = response.headers.get("ETag") - if(eTag === servedETag) { - return - } - + // Update ETag ETAGS.set(url, eTag) - let message = { - type: "new content", - url + // Get CSP + let oldCSP = this.currentCSP + let csp = response.headers.get("Content-Security-Policy") + + if(csp != oldCSP) { + this.currentCSP = csp + + if(oldCSP !== "") { + return this.forceClientReloadPage(url, evt.source) + } } - let cacheRefresh = CACHEREFRESH.get(url) - - if(!cacheRefresh) { - console.log("forcing reload, cache refresh null") - return evt.source.postMessage(JSON.stringify(message)) + // If the ETag changed, we need to do a reload. + // If the CSP and therefore the sha-1 hash of the CSS changed, we need to do a reload. + if(eTag !== servedETag) { + return this.forceClientReloadContent(url, evt.source) } - - return cacheRefresh.then(() => { - console.log("forcing reload after cache refresh") - evt.source.postMessage(JSON.stringify(message)) - }) + + // Do nothing + return Promise.resolve() }) - ) -}) - -self.addEventListener("fetch", async (evt: FetchEvent) => { - let request = evt.request as Request - let isAuth = request.url.includes("/auth/") || request.url.includes("/logout") - let ignoreCache = false - - // console.log("fetch:", request.url) - - // Exclude certain URLs from being cached - for(let pattern of EXCLUDECACHE.keys()) { - if(request.url.includes(pattern)) { - ignoreCache = true - break - } } - // Delete existing cache on authentication - if(isAuth) { - caches.delete(CACHE) - } - - // Do not use cache in some cases - if(request.method !== "GET" || isAuth || ignoreCache) { - return evt.waitUntil(evt.respondWith(fetch(request))) - } - - // Forced cache response? - if(request.headers.get("X-CacheOnly") === "true") { - // console.log("forced cache response") - return evt.waitUntil(fromCache(request)) - } - - let servedETag = undefined + onPush(evt: PushEvent) { + var payload = evt.data ? evt.data.json() : {} - // Start fetching the request - let refresh = fetch(request).then(response => { - // console.log(response) - let clone = response.clone() - - // Save the new version of the resource in the cache - let cacheRefresh = caches.open(CACHE).then(cache => { - return cache.put(request, clone) - }) - - CACHEREFRESH.set(request.url, cacheRefresh) - - return response - }) - - // Save in map - RELOADS.set(request.url, refresh) - - // Forced reload - if(request.headers.get("X-Reload") === "true") { - return evt.waitUntil(evt.respondWith(refresh.then(response => { - servedETag = response.headers.get("ETag") - ETAGS.set(request.url, servedETag) - return response - }))) - } - - // Try to serve cache first and fall back to network response - let networkOrCache = fromCache(request).then(response => { - // console.log("served from cache:", request.url) - servedETag = response.headers.get("ETag") - ETAGS.set(request.url, servedETag) - return response - }).catch(error => { - // console.log("Cache MISS:", request.url) - return refresh - }) - - return evt.waitUntil(evt.respondWith(networkOrCache)) -}) - -self.addEventListener("push", (evt: PushEvent) => { - var payload = evt.data ? evt.data.json() : {} - - evt.waitUntil( - (self as any).registration.showNotification(payload.title, { + return (self as any).registration.showNotification(payload.title, { body: payload.message, icon: payload.icon, image: payload.image, data: payload.link, - badge: "https://notify.moe/brand/64" + badge: "https://notify.moe/brand/64.png" }) - ) -}) + } -self.addEventListener("pushsubscriptionchange", (evt: any) => { - evt.waitUntil((self as any).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 + onPushSubscriptionChange(evt: any) { + return (self as any).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 user = await fetch("/api/me").then(response => response.json()) - - return fetch("/api/pushsubscriptions/" + user.id + "/add", { - method: "POST", - credentials: "same-origin", - body: JSON.stringify(pushSubscription) + + let user = await fetch("/api/me").then(response => response.json()) + + return fetch("/api/pushsubscriptions/" + user.id + "/add", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(pushSubscription) + }) }) - })) -}) + } -self.addEventListener("notificationclick", (evt: NotificationEvent) => { - let notification = evt.notification - notification.close() - - evt.waitUntil( - (self as any).clients.matchAll().then(function(clientList) { + onNotificationClick(evt: NotificationEvent) { + let notification = evt.notification + notification.close() + + return (self as any).clients.matchAll().then(function(clientList) { // If we have a link, use that link to open a new window. let url = notification.data @@ -266,28 +283,60 @@ self.addEventListener("notificationclick", (evt: NotificationEvent) => { // Otherwise open a new window return (self as any).clients.openWindow("https://notify.moe") }) - ) -}) + } -function installCache() { - return caches.open(CACHE).then(cache => { - return cache.addAll([ - "./", - "./scripts", - "https://fonts.gstatic.com/s/ubuntu/v10/2Q-AW1e_taO6pHwMXcXW5w.ttf" - ]) - }) -} + forceClientReloadContent(url: string, eventSource: any) { + let message = { + type: "new content", + url + } -function fromCache(request) { - return caches.open(CACHE).then(cache => { - return cache.match(request).then(matching => { - if(matching) { - // console.log("Cache HIT:", request.url) - return Promise.resolve(matching) - } + this.postMessageAfterPromise(message, CACHEREFRESH.get(url), eventSource) + } - return Promise.reject("no-match") + forceClientReloadPage(url: string, eventSource: any) { + let message = { + type: "reload page", + url + } + + this.postMessageAfterPromise(message, RELOADS.get(url.replace("/_/", "/")), eventSource) + } + + postMessageAfterPromise(message: any, promise: Promise, eventSource: any) { + if(!promise) { + console.log("forcing reload, cache refresh null") + return eventSource.postMessage(JSON.stringify(message)) + } + + return promise.then(() => { + console.log("forcing reload after cache refresh") + eventSource.postMessage(JSON.stringify(message)) }) - }) + } + + installCache() { + return caches.open(this.cache.version).then(cache => { + return cache.addAll([ + "./", + "./scripts", + "https://fonts.gstatic.com/s/ubuntu/v10/2Q-AW1e_taO6pHwMXcXW5w.ttf" + ]) + }) + } + + fromCache(request) { + return caches.open(this.cache.version).then(cache => { + return cache.match(request).then(matching => { + if(matching) { + // console.log("Cache HIT:", request.url) + return Promise.resolve(matching) + } + + return Promise.reject("no-match") + }) + }) + } } + +const serviceWorker = new MyServiceWorker()