pinafore/templates/service-worker.js
Nolan Lawson 7876f82871
fix: build inline script using Rollup (#761)
* fix: build inline script using Rollup

This reduces code duplication and allows the theme engine to work the
same without modifying the code in two places. It does extra extra deps,
but I tried to keep them to a minimum.

* change code comment

* remove unnecessary constant
2018-12-08 11:21:54 -08:00

305 lines
8.7 KiB
JavaScript

const timestamp = '__timestamp__'
const ASSETS = `assets_${timestamp}`
const WEBPACK_ASSETS = `webpack_assets_${timestamp}`
// `assets` is an array of everything in the `assets` directory
const assets = __assets__
.map(file => file.startsWith('/') ? file : `/${file}`)
.filter(filename => !filename.startsWith('/apple-icon'))
.filter(filename => !filename.endsWith('.map'))
.concat(['/index.html'])
// `shell` is an array of all the files generated by webpack
// also contains '/index.html' for some reason
const webpackAssets = __shell__
.filter(filename => !filename.endsWith('.map'))
.filter(filename => filename !== '/index.html')
// `routes` is an array of `{ pattern: RegExp }` objects that
// match the pages in your app
const routes = __routes__
self.addEventListener('install', event => {
event.waitUntil((async () => {
await Promise.all([
caches.open(WEBPACK_ASSETS).then(cache => cache.addAll(webpackAssets)),
caches.open(ASSETS).then(cache => cache.addAll(assets))
])
self.skipWaiting()
})())
})
self.addEventListener('activate', event => {
event.waitUntil((async () => {
let keys = await caches.keys()
// delete old asset/ondemand caches
for (let key of keys) {
if (key !== ASSETS &&
!key.startsWith('webpack_assets_')) {
await caches.delete(key)
}
}
// for webpack assets, keep the two latest builds because we may need
// them when the service worker has installed but the page has not
// yet reloaded (e.g. when it gives the toast saying "please reload"
// but then you don't refresh and instead load an async chunk)
let webpackKeysToDelete = keys
.filter(key => key.startsWith('webpack_assets_'))
.sort((a, b) => {
let aTimestamp = parseInt(a.substring(15), 10)
let bTimestamp = parseInt(b.substring(15), 10)
return bTimestamp < aTimestamp ? -1 : 1
})
.slice(2)
for (let key of webpackKeysToDelete) {
await caches.delete(key)
}
await self.clients.claim()
})())
})
self.addEventListener('fetch', event => {
const req = event.request
const url = new URL(req.url)
// don't try to handle e.g. data: URIs
if (!url.protocol.startsWith('http')) {
return
}
event.respondWith((async () => {
let sameOrigin = url.origin === self.origin
if (sameOrigin) {
// always serve webpack-generated resources and
// assets from the cache if possible
let response = await caches.match(req)
if (response) {
return response
}
// for routes, serve the /index.html file from the most recent
// assets cache
if (routes.find(route => route.pattern.test(url.pathname))) {
let response = await caches.match('/index.html')
if (response) {
return response
}
}
}
// for everything else, go network-only
return fetch(req)
})())
})
self.addEventListener('push', event => {
event.waitUntil((async () => {
const data = event.data.json()
const { origin } = new URL(data.icon)
try {
const notification = await get(`${origin}/api/v1/notifications/${data.notification_id}`, {
'Authorization': `Bearer ${data.access_token}`
}, { timeout: 2000 })
await showRichNotification(data, notification)
} catch (e) {
await showSimpleNotification(data)
}
})())
})
async function showSimpleNotification (data) {
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body
})
}
async function showRichNotification (data, notification) {
const { origin } = new URL(data.icon)
switch (notification.type) {
case 'follow': {
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body,
tag: notification.id,
data: {
url: `${self.location.origin}/accounts/${notification.account.id}`
}
})
break
}
case 'mention': {
const actions = [{
action: 'favourite',
title: 'Favourite'
}]
if ('reply' in NotificationEvent.prototype) {
actions.splice(0, 0, {
action: 'reply',
type: 'text',
title: 'Reply'
})
}
if (['public', 'unlisted'].includes(notification.status.visibility)) {
actions.push({
action: 'reblog',
title: 'Boost'
})
}
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body,
tag: notification.id,
data: {
instance: origin,
status_id: notification.status.id,
access_token: data.access_token,
url: `${self.location.origin}/statuses/${notification.status.id}`
},
actions
})
break
}
case 'reblog': {
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body,
tag: notification.id,
data: {
url: `${self.location.origin}/statuses/${notification.status.id}`
}
})
break
}
case 'favourite': {
await self.registration.showNotification(data.title, {
icon: data.icon,
body: data.body,
tag: notification.id,
data: {
url: `${self.location.origin}/statuses/${notification.status.id}`
}
})
break
}
}
}
const cloneNotification = notification => {
const clone = { }
// Object.assign() does not work with notifications
for (let k in notification) {
clone[k] = notification[k]
}
return clone
}
const updateNotificationWithoutAction = (notification, action) => {
const newNotification = cloneNotification(notification)
newNotification.actions = newNotification.actions.filter(item => item.action !== action)
return self.registration.showNotification(newNotification.title, newNotification)
}
self.addEventListener('notificationclick', event => {
event.waitUntil((async () => {
switch (event.action) {
case 'reply': {
await post(`${event.notification.data.instance}/api/v1/statuses/`, {
status: event.reply,
in_reply_to_id: event.notification.data.status_id
}, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
await updateNotificationWithoutAction(event.notification, 'reply')
break
}
case 'reblog': {
await post(`${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/reblog`, null, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
await updateNotificationWithoutAction(event.notification, 'reblog')
break
}
case 'favourite': {
await post(`${event.notification.data.instance}/api/v1/statuses/${event.notification.data.status_id}/favourite`, null, { 'Authorization': `Bearer ${event.notification.data.access_token}` })
await updateNotificationWithoutAction(event.notification, 'favourite')
break
}
default: {
await self.clients.openWindow(event.notification.data.url)
await event.notification.close()
break
}
}
})())
})
// Copy-paste from ajax.js
async function get (url, headers, options) {
return _fetch(url, makeFetchOptions('GET', headers), options)
}
async function post (url, body, headers, options) {
return _putOrPostOrPatch('POST', url, body, headers, options)
}
async function _putOrPostOrPatch (method, url, body, headers, options) {
let fetchOptions = makeFetchOptions(method, headers)
if (body) {
if (body instanceof FormData) {
fetchOptions.body = body
} else {
fetchOptions.body = JSON.stringify(body)
fetchOptions.headers['Content-Type'] = 'application/json'
}
}
return _fetch(url, fetchOptions, options)
}
async function _fetch (url, fetchOptions, options) {
let response
if (options && options.timeout) {
response = await fetchWithTimeout(url, fetchOptions, options.timeout)
} else {
response = await fetch(url, fetchOptions)
}
return throwErrorIfInvalidResponse(response)
}
async function throwErrorIfInvalidResponse (response) {
let json = await response.json()
if (response.status >= 200 && response.status < 300) {
return json
}
if (json && json.error) {
throw new Error(response.status + ': ' + json.error)
}
throw new Error('Request failed: ' + response.status)
}
function fetchWithTimeout (url, fetchOptions, timeout) {
return new Promise((resolve, reject) => {
fetch(url, fetchOptions).then(resolve, reject)
setTimeout(() => reject(new Error(`Timed out after ${timeout / 1000} seconds`)), timeout)
})
}
function makeFetchOptions (method, headers) {
return {
method,
headers: Object.assign(headers || {}, {
'Accept': 'application/json'
})
}
}