Add PWA support
This commit is contained in:
parent
b959371784
commit
f3ccfcbe48
|
@ -0,0 +1 @@
|
|||
Fixes service worker (#1634)
|
|
@ -0,0 +1 @@
|
|||
Handle PWA correctly and provide better cache strategy for album covers (#1721)
|
|
@ -24,10 +24,22 @@ module.exports = {
|
|||
'vue/no-v-html': 'off', // TODO: tackle this properly
|
||||
'vue/no-use-v-if-with-v-for': 'off',
|
||||
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
// NOTE: Handled by typescript
|
||||
'no-undef': 'off',
|
||||
'no-unused-vars': 'off',
|
||||
|
||||
// TODO (wvffle): Migrate to VUI
|
||||
// We're using `// @ts-ignore` in jQuery extensions
|
||||
// and gettext for vue 2
|
||||
'@typescript-eslint/ban-ts-comment': 'off',
|
||||
|
||||
// TODO (wvffle): Enable typescript rules later
|
||||
'@typescript-eslint/no-this-alias': 'off',
|
||||
'@typescript-eslint/no-empty-function': 'off'
|
||||
'@typescript-eslint/no-empty-function': 'off',
|
||||
'@typescript-eslint/no-unused-vars': 'off',
|
||||
|
||||
// TODO (wvffle): Migration to pinia
|
||||
// Vuex 3 store does not have types defined, hence we use `any`
|
||||
'@typescript-eslint/no-explicit-any': 'off'
|
||||
}
|
||||
}
|
||||
|
|
|
@ -50,6 +50,7 @@
|
|||
"@babel/core": "7.17.12",
|
||||
"@babel/plugin-transform-runtime": "7.17.12",
|
||||
"@babel/preset-env": "7.16.11",
|
||||
"@types/jest": "27.4.1",
|
||||
"@types/jquery": "3.5.14",
|
||||
"@types/lodash-es": "4.17.6",
|
||||
"@typescript-eslint/eslint-plugin": "5.19.0",
|
||||
|
@ -72,12 +73,18 @@
|
|||
"jest-cli": "27.5.1",
|
||||
"moxios": "0.4.0",
|
||||
"sinon": "13.0.2",
|
||||
"ts-jest": "27.1.4",
|
||||
"typescript": "4.6.3",
|
||||
"unplugin-vue2-script-setup": "0.10.2",
|
||||
"vite": "2.8.6",
|
||||
"vite-plugin-pwa": "0.12.0",
|
||||
"vite-plugin-vue2": "1.9.3",
|
||||
"vue-jest": "3.0.7",
|
||||
"vue-template-compiler": "2.6.14"
|
||||
"vue-template-compiler": "2.6.14",
|
||||
"workbox-core": "6.5.3",
|
||||
"workbox-precaching": "6.5.3",
|
||||
"workbox-routing": "6.5.3",
|
||||
"workbox-strategies": "6.5.3"
|
||||
},
|
||||
"resolutions": {
|
||||
"vue-plyr/plyr": "3.6.12"
|
||||
|
@ -132,14 +139,19 @@
|
|||
],
|
||||
"jest": {
|
||||
"moduleFileExtensions": [
|
||||
"ts",
|
||||
"js",
|
||||
"json",
|
||||
"vue"
|
||||
],
|
||||
"transform": {
|
||||
".*\\.(vue)$": "vue-jest",
|
||||
"^.+\\.js$": "babel-jest"
|
||||
"^.+\\.js$": "babel-jest",
|
||||
"^.+\\.ts$": "ts-jest"
|
||||
},
|
||||
"transformIgnorePatterns": [
|
||||
"<rootDir>/node_modules/(?!lodash-es/.*)"
|
||||
],
|
||||
"moduleNameMapper": {
|
||||
"^~/(.*)$": "<rootDir>/src/$1"
|
||||
},
|
||||
|
|
|
@ -1,93 +0,0 @@
|
|||
/* eslint no-undef: "off" */
|
||||
|
||||
// This is the code piece that GenerateSW mode can't provide for us.
|
||||
// This code listens for the user's confirmation to update the app.
|
||||
workbox.loadModule('workbox-routing')
|
||||
workbox.loadModule('workbox-strategies')
|
||||
workbox.loadModule('workbox-expiration')
|
||||
|
||||
self.addEventListener('message', (e) => {
|
||||
if (!e.data) {
|
||||
return
|
||||
}
|
||||
console.log('[sw] received message', e.data)
|
||||
switch (e.data.command) {
|
||||
case 'skipWaiting':
|
||||
self.skipWaiting()
|
||||
break
|
||||
case 'serverChosen':
|
||||
self.registerServerRoutes(e.data.serverUrl)
|
||||
break
|
||||
default:
|
||||
// NOOP
|
||||
break
|
||||
}
|
||||
})
|
||||
workbox.core.clientsClaim()
|
||||
|
||||
const router = new workbox.routing.Router()
|
||||
router.addCacheListener()
|
||||
router.addFetchListener()
|
||||
|
||||
let registeredServerRoutes = []
|
||||
self.registerServerRoutes = (serverUrl) => {
|
||||
console.log('[sw] Setting up API caching for', serverUrl)
|
||||
registeredServerRoutes.forEach((r) => {
|
||||
console.log('[sw] Unregistering previous API route...', r)
|
||||
router.unregisterRoute(r)
|
||||
})
|
||||
if (!serverUrl) {
|
||||
return
|
||||
}
|
||||
const regexReadyServerUrl = serverUrl.replace('.', '\\.')
|
||||
registeredServerRoutes = []
|
||||
const networkFirstPaths = [
|
||||
'api/v1/',
|
||||
'media/'
|
||||
]
|
||||
const networkFirstExcludedPaths = [
|
||||
'api/v1/listen'
|
||||
]
|
||||
const strategy = new workbox.strategies.NetworkFirst({
|
||||
cacheName: 'api-cache:' + serverUrl,
|
||||
plugins: [
|
||||
new workbox.expiration.Plugin({
|
||||
maxAgeSeconds: 24 * 60 * 60 * 7
|
||||
})
|
||||
]
|
||||
})
|
||||
const networkFirstRoutes = networkFirstPaths.map((path) => {
|
||||
const regex = new RegExp(regexReadyServerUrl + path)
|
||||
return new workbox.routing.RegExpRoute(regex, () => {})
|
||||
})
|
||||
const matcher = ({ url, event }) => {
|
||||
for (let index = 0; index < networkFirstExcludedPaths.length; index++) {
|
||||
const blacklistedPath = networkFirstExcludedPaths[index]
|
||||
if (url.pathname.startsWith('/' + blacklistedPath)) {
|
||||
// the path is blacklisted, we don't cache it at all
|
||||
console.log('[sw] Path is blacklisted, not caching', url.pathname)
|
||||
return false
|
||||
}
|
||||
}
|
||||
// we call other regex matchers
|
||||
for (let index = 0; index < networkFirstRoutes.length; index++) {
|
||||
const route = networkFirstRoutes[index]
|
||||
const result = route.match({ url, event })
|
||||
if (result) {
|
||||
return result
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
const route = new workbox.routing.Route(matcher, strategy)
|
||||
console.log('[sw] registering new API route...', route)
|
||||
router.registerRoute(route)
|
||||
registeredServerRoutes.push(route)
|
||||
}
|
||||
|
||||
// The precaching code provided by Workbox.
|
||||
self.__precacheManifest = [].concat(self.__precacheManifest || [])
|
||||
|
||||
// workbox.precaching.suppressWarnings(); // Only used with Vue CLI 3 and Workbox v3.
|
||||
workbox.precaching.precacheAndRoute(self.__precacheManifest, {})
|
|
@ -111,64 +111,6 @@ const { width } = useWindowSize()
|
|||
const player = ref()
|
||||
const showShortcutsModal = ref(false)
|
||||
const showSetInstanceModal = ref(false)
|
||||
// export default {
|
||||
// computed: {
|
||||
// ...mapState({
|
||||
// serviceWorker: state => state.ui.serviceWorker
|
||||
// }),
|
||||
// },
|
||||
// watch: {
|
||||
// 'serviceWorker.updateAvailable': {
|
||||
// handler (v) {
|
||||
// if (!v) {
|
||||
// return
|
||||
// }
|
||||
// const self = this
|
||||
// this.$store.commit('ui/addMessage', {
|
||||
// content: this.$pgettext('App/Message/Paragraph', 'A new version of the app is available.'),
|
||||
// date: new Date(),
|
||||
// key: 'refreshApp',
|
||||
// displayTime: 0,
|
||||
// classActions: 'bottom attached opaque',
|
||||
// actions: [
|
||||
// {
|
||||
// text: this.$pgettext('App/Message/Paragraph', 'Update'),
|
||||
// class: 'primary',
|
||||
// click: function () {
|
||||
// self.updateApp()
|
||||
// }
|
||||
// },
|
||||
// {
|
||||
// text: this.$pgettext('App/Message/Paragraph', 'Later'),
|
||||
// class: 'basic'
|
||||
// }
|
||||
// ]
|
||||
// })
|
||||
// },
|
||||
// immediate: true
|
||||
// }
|
||||
// },
|
||||
// async created () {
|
||||
// if (navigator.serviceWorker) {
|
||||
// navigator.serviceWorker.addEventListener(
|
||||
// 'controllerchange', () => {
|
||||
// if (this.serviceWorker.refreshing) return
|
||||
// this.$store.commit('ui/serviceWorker', {
|
||||
// refreshing: true
|
||||
// })
|
||||
// window.location.reload()
|
||||
// }
|
||||
// )
|
||||
// }
|
||||
// },
|
||||
// methods: {
|
||||
// updateApp () {
|
||||
// this.$store.commit('ui/serviceWorker', { updateAvailable: false })
|
||||
// if (!this.serviceWorker.registration || !this.serviceWorker.registration.waiting) { return }
|
||||
// this.serviceWorker.registration.waiting.postMessage({ command: 'skipWaiting' })
|
||||
// },
|
||||
// }
|
||||
// }
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
|
@ -1,45 +1,40 @@
|
|||
import { InitModule } from '~/types'
|
||||
import { register } from 'register-service-worker'
|
||||
import { AppModule } from '~/types'
|
||||
import { registerSW } from 'virtual:pwa-register'
|
||||
import logger from '~/logging'
|
||||
import Vue from 'vue'
|
||||
|
||||
export const install: InitModule = ({ store }) => {
|
||||
if (import.meta.env.PROD) {
|
||||
register(`${import.meta.env.BASE_URL}service-worker.js`, {
|
||||
registrationOptions: { scope: '/' },
|
||||
ready () {
|
||||
console.log(
|
||||
'App is being served from cache by a service worker.'
|
||||
)
|
||||
const { $pgettext } = Vue.prototype
|
||||
|
||||
export const install: AppModule = ({ store }) => {
|
||||
const updateSW = registerSW({
|
||||
onRegisterError () {
|
||||
logger.default.error('SW install error')
|
||||
},
|
||||
registered (registration) {
|
||||
console.log('Service worker has been registered.')
|
||||
// check for updates every 2 hours
|
||||
const checkInterval = 1000 * 60 * 60 * 2
|
||||
// var checkInterval = 1000 * 5
|
||||
setInterval(() => {
|
||||
console.log('Checking for service worker update…')
|
||||
registration.update()
|
||||
}, checkInterval)
|
||||
store.commit('ui/serviceWorker', { registration: registration })
|
||||
if (registration.active) {
|
||||
registration.active.postMessage({ command: 'serverChosen', serverUrl: store.state.instance.instanceUrl })
|
||||
}
|
||||
},
|
||||
cached () {
|
||||
console.log('Content has been cached for offline use.')
|
||||
},
|
||||
updatefound () {
|
||||
console.log('New content is downloading.')
|
||||
},
|
||||
updated (registration) {
|
||||
console.log('New content is available; please refresh!')
|
||||
store.commit('ui/serviceWorker', { updateAvailable: true, registration: registration })
|
||||
},
|
||||
offline () {
|
||||
console.log('No internet connection found. App is running in offline mode.')
|
||||
},
|
||||
error (error) {
|
||||
console.error('Error during service worker registration:', error)
|
||||
onOfflineReady () {
|
||||
logger.default.info('Funkwhale is being served from cache by a service worker.')
|
||||
},
|
||||
onRegistered () {
|
||||
logger.default.info('Service worker has been registered.')
|
||||
},
|
||||
onNeedRefresh () {
|
||||
store.commit('ui/addMessage', {
|
||||
content: $pgettext('App/Message/Paragraph', 'A new version of the app is available.'),
|
||||
date: new Date(),
|
||||
key: 'refreshApp',
|
||||
displayTime: 0,
|
||||
classActions: 'bottom attached opaque',
|
||||
actions: [
|
||||
{
|
||||
text: $pgettext('App/Message/Paragraph', 'Update'),
|
||||
class: 'primary',
|
||||
click: () => updateSW()
|
||||
},
|
||||
{
|
||||
text: $pgettext('App/Message/Paragraph', 'Later'),
|
||||
class: 'basic'
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching'
|
||||
import { NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies'
|
||||
import { ExpirationPlugin } from 'workbox-expiration'
|
||||
import { registerRoute } from 'workbox-routing'
|
||||
import { clientsClaim } from 'workbox-core'
|
||||
|
||||
declare let self: ServiceWorkerGlobalScope
|
||||
|
||||
// NOTE: Clean up outdated caches
|
||||
// With each new production build, all precached assets
|
||||
// that were modified are added to the cache. The old versions
|
||||
// need to be removed manually.
|
||||
cleanupOutdatedCaches()
|
||||
|
||||
// Let new service worker claim control of already open web pages
|
||||
// https://developer.chrome.com/docs/workbox/modules/workbox-core/#clients-claim
|
||||
clientsClaim()
|
||||
|
||||
// Support for an update prompt handled by VitePWA:
|
||||
// https://vite-plugin-pwa.netlify.app/guide/prompt-for-update.html
|
||||
self.addEventListener('message', (event) => {
|
||||
if (event.data?.type === 'SKIP_WAITING') {
|
||||
return self.skipWaiting()
|
||||
}
|
||||
})
|
||||
|
||||
// NOTE: Network-First cache for API calls
|
||||
// We're using cache only when the user goes offline
|
||||
registerRoute(({ url }) => {
|
||||
if (url.pathname.startsWith('/api/v1/listen')) return false
|
||||
return url.pathname.startsWith('/api/v1')
|
||||
}, new NetworkFirst({
|
||||
plugins: [
|
||||
// Expire after a week
|
||||
new ExpirationPlugin({ maxAgeSeconds: 7 * 24 * 3600 })
|
||||
]
|
||||
}))
|
||||
|
||||
// NOTE: Stale-While-Revalidate cache for album covers
|
||||
// We're serving from cache if available and making a request
|
||||
// in the background to update the cache for next request
|
||||
registerRoute(({ url }) => {
|
||||
return url.pathname.startsWith('/media')
|
||||
}, new StaleWhileRevalidate())
|
||||
|
||||
// Precache all assets and add routes for them
|
||||
// https://developer.chrome.com/docs/workbox/reference/workbox-precaching/#method-precacheAndRoute
|
||||
precacheAndRoute(self.__WB_MANIFEST)
|
|
@ -9,12 +9,6 @@ function getDefaultUrl () {
|
|||
)
|
||||
}
|
||||
|
||||
function notifyServiceWorker (registration, message) {
|
||||
if (registration && registration.active) {
|
||||
registration.active.postMessage(message)
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
namespaced: true,
|
||||
state: {
|
||||
|
@ -87,7 +81,7 @@ export default {
|
|||
value = value + '/'
|
||||
}
|
||||
state.instanceUrl = value
|
||||
notifyServiceWorker(state.registration, { command: 'serverChosen', serverUrl: state.instanceUrl })
|
||||
|
||||
// append the URL to the list (and remove existing one if needed)
|
||||
if (value) {
|
||||
const index = state.knownInstances.indexOf(value)
|
||||
|
|
|
@ -174,11 +174,6 @@ export default {
|
|||
orderingDirection: '-',
|
||||
ordering: 'creation_date'
|
||||
}
|
||||
},
|
||||
serviceWorker: {
|
||||
refreshing: false,
|
||||
registration: null,
|
||||
updateAvailable: false
|
||||
}
|
||||
},
|
||||
getters: {
|
||||
|
@ -310,9 +305,6 @@ export default {
|
|||
state.routePreferences[route].orderingDirection = value
|
||||
},
|
||||
|
||||
serviceWorker: (state, value) => {
|
||||
state.serviceWorker = { ...state.serviceWorker, ...value }
|
||||
},
|
||||
window: (state, value) => {
|
||||
state.window = value
|
||||
}
|
||||
|
|
|
@ -19,8 +19,9 @@ export function parseAPIErrors (responseData: APIErrorResponse, parentField?: st
|
|||
}
|
||||
|
||||
const value = responseData[field]
|
||||
if (value as string[]) {
|
||||
errors.push(...(value as string[]).map(err => {
|
||||
if (Array.isArray(value)) {
|
||||
const values = value as string[]
|
||||
errors.push(...values.map(err => {
|
||||
return err.toLocaleLowerCase().includes('this field ')
|
||||
? `${fieldName}: ${err}`
|
||||
: err
|
||||
|
|
|
@ -19,7 +19,7 @@ export default {
|
|||
|
||||
return hours >= 1
|
||||
? `${hours}:${pad(min)}:${pad(sec)}`
|
||||
: `${pad(min)}:${pad(sec)}`
|
||||
: `${min}:${pad(sec)}`
|
||||
},
|
||||
durationFormatted (v: string) {
|
||||
const duration = parseInt(v)
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
import {expect} from 'chai'
|
||||
import moment from 'moment'
|
||||
import {truncate, ago, capitalize, year, unique} from '~/filters'
|
||||
import {truncate, ago, capitalize, year, unique} from '~/init/filters'
|
||||
|
||||
describe('filters', () => {
|
||||
describe('truncate', () => {
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
var sinon = require('sinon')
|
||||
import {expect} from 'chai'
|
||||
import * as _ from 'lodash-es'
|
||||
|
||||
import store from '~/store/queue'
|
||||
import { testAction } from '../../utils'
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
"baseUrl": ".",
|
||||
"module": "ESNext",
|
||||
"target": "ESNext",
|
||||
"lib": ["DOM", "ESNext"],
|
||||
"lib": ["DOM", "ESNext", "WebWorker"],
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"jsx": "preserve",
|
||||
|
|
|
@ -1,9 +1,8 @@
|
|||
import { defineConfig, HmrOptions } from 'vite'
|
||||
import { createVuePlugin as Vue2 } from 'vite-plugin-vue2'
|
||||
import ScriptSetup from 'unplugin-vue2-script-setup/vite'
|
||||
|
||||
// @ts-ignore
|
||||
import path from 'path'
|
||||
import { VitePWA } from 'vite-plugin-pwa'
|
||||
import { resolve } from 'path'
|
||||
|
||||
const port = +(process.env.VUE_PORT ?? 8080)
|
||||
|
||||
|
@ -29,6 +28,18 @@ export default defineConfig(() => ({
|
|||
// https://github.com/antfu/unplugin-vue2-script-setup
|
||||
ScriptSetup(),
|
||||
|
||||
// https://github.com/antfu/vite-plugin-pwa
|
||||
VitePWA({
|
||||
strategies: 'injectManifest',
|
||||
srcDir: 'src',
|
||||
filename: 'serviceWorker.ts',
|
||||
devOptions: {
|
||||
enabled: true,
|
||||
type: 'module',
|
||||
navigateFallback: 'index.html'
|
||||
}
|
||||
}),
|
||||
|
||||
{
|
||||
name: 'fix-fomantic-ui-css',
|
||||
transform (src, id) {
|
||||
|
@ -41,7 +52,7 @@ export default defineConfig(() => ({
|
|||
server: { port, hmr },
|
||||
resolve: {
|
||||
alias: {
|
||||
'~': path.resolve(__dirname, './src')
|
||||
'~': resolve(__dirname, './src')
|
||||
}
|
||||
},
|
||||
build: {
|
||||
|
|
840
front/yarn.lock
840
front/yarn.lock
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue