diff --git a/scripts/Application.ts b/scripts/Application.ts index f7159078..197efc1c 100644 --- a/scripts/Application.ts +++ b/scripts/Application.ts @@ -13,6 +13,7 @@ export class Application { loading: HTMLElement currentPath: string originalPath: string + lastRequest: XMLHttpRequest | null constructor() { this.currentPath = window.location.pathname @@ -34,9 +35,32 @@ export class Application { } get(url: string): Promise { - return fetch(url, { - credentials: "same-origin" - }).then(response => response.text()) + // return fetch(url, { + // credentials: "same-origin" + // }).then(response => response.text()) + + if(this.lastRequest) { + this.lastRequest.abort() + this.lastRequest = null + } + + return new Promise((resolve, reject) => { + let request = new XMLHttpRequest() + + 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 = () => { + if(request.status < 200 || request.status >= 400) + reject(request.responseText) + else + resolve(request.responseText) + } + + request.open("GET", url, true) + request.send() + + this.lastRequest = request + }) } load(url: string, options?: LoadOptions) { diff --git a/sw/index.d.ts b/sw/index.d.ts index 0fb62d00..f7ee7450 100644 --- a/sw/index.d.ts +++ b/sw/index.d.ts @@ -652,5 +652,11 @@ interface ServiceWorkerGlobalScope extends EventTarget { ): void; } +interface NotificationOptions { + image?: string; + data?: string; + badge?: string; +} + // tslint:disable-next-line no-empty-interface interface Window extends ServiceWorkerGlobalScope {} \ No newline at end of file diff --git a/sw/service-worker.ts b/sw/service-worker.ts index 4aaa4695..347555e9 100644 --- a/sw/service-worker.ts +++ b/sw/service-worker.ts @@ -43,6 +43,242 @@ const EXCLUDECACHE = new Set([ // Authorization paths /auth/ and /logout are not listed here because they are handled in a special way. ]) + +// MyServiceWorker is the process that controls all the tabs in a browser. +class MyServiceWorker { + cache: MyCache + + constructor() { + this.cache = new MyCache("v-5") + + 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))) + } + + onInstall(evt: InstallEvent) { + console.log("service worker install") + + return self.skipWaiting().then(() => { + return 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(evt: FetchEvent) { + let request = evt.request as Request + + // If it's not a GET request, fetch it normally + if(request.method !== "GET") { + return evt.respondWith(fetch(request)) + } + + // Clear cache on authentication and fetch it normally + if(request.url.includes("/auth/") || request.url.includes("/logout")) { + return evt.respondWith(caches.delete(this.cache.version).then(() => 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 evt.respondWith(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 = this.cache.store(request, clone).catch(err => { + console.error(err) + // TODO: Tell client that the quota is exceeded (disk full). + }) + + CACHEREFRESH.set(request.url, cacheRefresh) + + // Force reload page if styles changed + if(request.url.endsWith("/styles")) { + let servedETag = ETAGS.get(request.url) + let newETag = response.headers.get("ETag") + console.log("/styles fetched", servedETag, newETag) + + if(servedETag && servedETag !== newETag) { + cacheRefresh.then(async () => { + console.log("tell client to reload style") + let client = await MyClient.get(evt.clientId) + client.reloadStyles() + }) + } + } + + 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) + } + + 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(evt: PushEvent) { + var payload = evt.data ? evt.data.json() : {} + + return self.registration.showNotification(payload.title, { + body: payload.message, + icon: payload.icon, + image: payload.image, + data: payload.link, + badge: "https://notify.moe/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 user = await fetch("/api/me", { + credentials: "same-origin" + }).then(response => response.json()) + + return fetch("/api/pushsubscriptions/" + user.id + "/add", { + method: "POST", + credentials: "same-origin", + body: JSON.stringify(pushSubscription) + }) + }) + } + + 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") + }) + } + + installCache() { + // TODO: Implement a solution that caches resources with credentials: "same-origin" + return Promise.resolve() + + // return caches.open(this.cache.version).then(cache => { + // return cache.addAll([ + // "./", + // "./scripts", + // "https://fonts.gstatic.com/s/ubuntu/v11/4iCs6KVjbNBYlgoKfw7z.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") + }) + }) + } +} + +// MyCache is the cache used by the service worker. class MyCache { version: string @@ -58,6 +294,7 @@ class MyCache { } } +// MyClient represents a single tab in the browser. class MyClient { client: ServiceWorkerClient @@ -192,237 +429,4 @@ class MyClient { } } -class MyServiceWorker { - cache: MyCache - - constructor() { - this.cache = new MyCache("v-5") - - 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))) - } - - onInstall(evt: InstallEvent) { - console.log("service worker install") - - return (self as any).skipWaiting().then(() => { - return 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(evt: FetchEvent) { - let request = evt.request as Request - - // If it's not a GET request, fetch it normally - if(request.method !== "GET") { - return evt.respondWith(fetch(request)) - } - - // Clear cache on authentication and fetch it normally - if(request.url.includes("/auth/") || request.url.includes("/logout")) { - return evt.respondWith(caches.delete(this.cache.version).then(() => 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 evt.respondWith(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 = this.cache.store(request, clone).catch(err => { - console.error(err) - // TODO: Tell client that the quota is exceeded (disk full). - }) - - CACHEREFRESH.set(request.url, cacheRefresh) - - // Force reload page if styles changed - if(request.url.endsWith("/styles")) { - let servedETag = ETAGS.get(request.url) - let newETag = response.headers.get("ETag") - console.log("/styles fetched", servedETag, newETag) - - if(servedETag && servedETag !== newETag) { - cacheRefresh.then(async () => { - console.log("tell client to reload style") - let client = await MyClient.get(evt.clientId) - client.reloadStyles() - }) - } - } - - 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) - } - - 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(evt: PushEvent) { - var payload = evt.data ? evt.data.json() : {} - - 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.png" - }) - } - - 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", { - credentials: "same-origin" - }).then(response => response.json()) - - return fetch("/api/pushsubscriptions/" + user.id + "/add", { - method: "POST", - credentials: "same-origin", - body: JSON.stringify(pushSubscription) - }) - }) - } - - 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") - }) - } - - installCache() { - // TODO: Implement a solution that caches resources with credentials: "same-origin" - return Promise.resolve() - - // return caches.open(this.cache.version).then(cache => { - // return cache.addAll([ - // "./", - // "./scripts", - // "https://fonts.gstatic.com/s/ubuntu/v11/4iCs6KVjbNBYlgoKfw7z.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()