public/sw.js
const version = '0.1.0'
let serverUrl
// Cache IDs
let coreID = `core_${version}`
let pageID = `pages_${version}`
let imgID = `img_${version}`
let apiID = `api_${version}`
let cacheIDs = [coreID, pageID, imgID, apiID]
// Max number of files in cache
const limits = { pages: 35, imgs: 20 }
const coreAssets = []
const messages = {
en: {
buyAHouse: 'buy a house',
changeMyJob: 'change my job',
boostMyCareer: 'boost my career',
looseWeight: 'loose weight',
payLessTaxes: 'pay less taxes',
makeNewFriends: 'make new friends',
becomeAnExpert: 'become an expert',
becomeAnAstronaut: 'become an astronaut',
flyToTheMoon: 'fly to the moon',
makeAWorldTour: 'make a world tour',
liveWithoutWorking: 'live without working',
findMyDreamjob: 'find my dreamjob',
becomeAPilot: 'become a pilot',
seeAnEclipse: 'see an eclipse',
swimWithDolphins: 'swim with dolphins',
becomeRich: 'become rich',
fallInLove: 'fall in love',
haveSex: 'have sex',
becomeHandsome: 'become handsome',
protectMyFamily: 'protect my family',
liveAlone: 'live alone',
findTheTruth: 'find the truth',
becomeZeroWaste: 'become zero waste',
buildMyOwnHouse: 'build my own house',
seeNorthernLights: 'see northern lights',
createACompany: 'create a company',
expandMyNetwork: 'expand my network',
getDrivingLicence: 'get a driving licence',
getANewPassport: 'get a new passport',
placeholder: 'what is your project?',
projectTitlePrefix: 'The steps to',
projectDescription1: 'This project is not available yet, we\'re working on it!',
projectDescription2: 'Write down your email, and we\'ll let you know as soon as it is.',
projectUnknownDescription1: 'We haven\'t identified this project, but we can work on it!',
projectUnknownDescription2: 'Write down your email, and we\'ll let you know when it will become available.',
projectSubmit: 'Let me know!',
projectAcknowledge: 'Thank you!',
goal1: 'You can do <em>anything</em>.',
goal2: 'Just follow <span class="steps">the steps</span> to'
}
}
const projects = [
'getDrivingLicence',
'haveSex',
'liveAlone',
'liveWithoutWorking',
'looseWeight',
'makeAWorldTour',
'makeNewFriends',
'payLessTaxes',
'protectMyFamily',
'seeAnEclipse',
'seeNorthernLights',
'swimWithDolphins',
'becomeAPilot',
'becomeAnAstronaut',
'becomeAnExpert',
'becomeHandsome',
'becomeRich',
'becomeZeroWaste',
'boostMyCareer',
'buildMyOwnHouse',
'buyAHouse',
'changeMyJob',
'createACompany',
'expandMyNetwork',
'fallInLove',
'findMyDreamjob',
'findTheTruth',
'flyToTheMoon',
'getANewPassport'
]
/**
* Remove cached items over a certain number.
*
* @param {String} key The cache key
* @param {number} max The max number of items allowed
*/
function trimCache (key, max) {
caches.open(key)
.then(cache => {
cache.keys()
.then(keys => {
if (keys.length > max) {
cache.delete(keys[0]).then(() => {
trimCache(key, max)
})
}
})
})
}
/**
* Check if cached API data is still valid.
*
* @param {Object} response The response object
* @param {Number} goodFor How long the response is good for, in milliseconds
* @return {Boolean} If true, cached data is valid
*/
function isValid (response, goodFor) {
if (response) {
const fetched = response.headers.get()
if (fetched && (parseFloat(fetched) + goodFor) > new Date().getTime()) {
return true
}
}
return false
}
self.addEventListener('install', event => {
serverUrl = new URL(location).searchParams.get('serverUrl')
// Activate immediately
self.skipWaiting()
// Cache core assets
event
.waitUntil(caches.open(coreID)
.then(cache => {
for (const asset of coreAssets) {
cache.add(new Request(asset))
}
return cache
}))
})
// On version update, remove old cached files
self.addEventListener('activate', event => {
event.waitUntil(caches.keys()
.then(keys => {
// Get the keys of the caches to remove
const keysToRemove = keys.filter(key => !cacheIDs.includes(key))
// Delete each cache
const removed = keysToRemove.map(key => caches.delete(key))
return Promise.all(removed)
})
.then(() => self.clients.claim())
)
})
// Listen for request events
self.addEventListener('fetch', event => {
const request = event.request
// Bug fix https://stackoverflow.com/a/49719964
if (request.cache === 'only-if-cached' && request.mode !== 'same-origin') {
return
}
const url = new URL(request.url)
const headers = request.headers
const acceptHeader = headers.get()
// HTML files
// Network-first
if (acceptHeader.includes('text/html')) {
event.respondWith(
fetch(request)
.then(response => {
// Create a copy of the response and save it to the cache
const copy = response.clone()
event.waitUntil(caches.open(pageID).then(cache => cache.put(request, copy)))
return response
})
.catch(e => {
// If there's no item in cache, respond with a fallback
return caches.match(request).then(response => response || caches.match('/offline.html'))
})
)
return
}
// CSS & JavaScript
// Offline-first
if (acceptHeader.includes('text/javascript')) {
event.respondWith(
caches.match(request)
.then(response => response || fetch(request))
)
return
}
// Images & Fonts
// Offline-first
if (acceptHeader.includes('image') || url.pathname.includes('your-web-font')) {
event.respondWith(
caches.match(request)
.then(response => response || fetch(request)
.then(response => {
// If the request is for an image, save a copy of it in cache
if (headers.get().includes("image")) {
const copy = response.clone()
event.waitUntil(caches.open(imgID).then(cache => cache.put(request, copy)))
}
return response
})
)
)
return
}
// API Calls
// Offline-first
if (url.href.startsWith(serverUrl) && request.method === 'GET') {
event.respondWith(
caches.match(request)
.then(response => {
// If there's a cached API, and it's still valid, use it
const cachedAPI = response
if (isValid(response, 1000 * 60 * 60 * 2)) {
return response
}
// Otherwise, make a fresh API call
return fetch(request)
.then(response => {
// Create a copy of the response and save it to the cache
const copy = response.clone()
event
.waitUntil(caches.open(apiID)
.then(cache => {
const headers = new Headers(copy.headers)
headers.append('sw-fetched-on', new Date().getTime())
return copy.blob().then(body => cache.put(
request,
new Response(body, {
status: copy.status,
statusText: copy.statusText,
headers: headers
})
)
)
})
.catch(e => {
if (cachedAPI) {
return cachedAPI
}
throw e
}))
return response
})
.catch(e => {
if (cachedAPI) {
return cachedAPI
}
let payload
const langs = headers.get().split(",") || ["*"]
const locale = langs[0].substring(0, 2)
const langMessage = messages[locale] || messages.en
switch (url.pathname) {
case '/lang':
payload = langMessage
break
case '/project':
payload = projects.map(project => ({
key: project,
title: langMessage[project]
}))
break
case '/user':
if (request.method === 'GET') {
const offset = new Date().getTimezoneOffset()
const user = {
locale: navigator.language.substring(0, 2),
languages: navigator.languages,
projects: []
}
const timeZone = 'UTC' + (offset < 0 ? '' : '+') + offset
payload = { timeZone, user }
}
break
}
if (payload) {
return new Response(JSON.stringify(payload), {
status: 200,
statusText: 'OK'
})
}
throw e
})
})
)
}
})
// Trim caches over a certain size
self.addEventListener('message', event => {
// Make sure the event was from a trusted site
// if (event.origin !== 'https://your-awesome-website.com') return;
// Only run on cleanUp messages
if (event.data !== 'cleanUp') {
return
}
trimCache('pages', limits.pages)
trimCache('img', limits.imgs)
})