diff --git a/build/sw_plugin.js b/build/sw_plugin.js
index 90ab856ad..a2c792b7d 100644
--- a/build/sw_plugin.js
+++ b/build/sw_plugin.js
@@ -11,6 +11,11 @@ const getSWMessagesAsText = async () => {
}
const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)))
+const swEnvName = 'virtual:pleroma-fe/service_worker_env'
+const swEnvNameResolved = '\0' + swEnvName
+const getDevSwEnv = () => `self.serviceWorkerOption = { assets: [] };`
+const getProdSwEnv = ({ assets }) => `self.serviceWorkerOption = { assets: ${JSON.stringify(assets)} };`
+
export const devSwPlugin = ({
swSrc,
swDest,
@@ -32,12 +37,16 @@ export const devSwPlugin = ({
const name = id.startsWith('/') ? id.slice(1) : id
if (name === swDest) {
return swFullSrc
+ } else if (name === swEnvName) {
+ return swEnvNameResolved
}
return null
},
async load (id) {
if (id === swFullSrc) {
return readFile(swFullSrc, 'utf-8')
+ } else if (id === swEnvNameResolved) {
+ return getDevSwEnv()
}
return null
},
@@ -79,6 +88,21 @@ export const devSwPlugin = ({
contents: await getSWMessagesAsText()
}))
}
+ }, {
+ name: 'sw-env',
+ setup (b) {
+ b.onResolve(
+ { filter: new RegExp('^' + swEnvName + '$') },
+ args => ({
+ path: args.path,
+ namespace: 'sw-env'
+ }))
+ b.onLoad(
+ { filter: /.*/, namespace: 'sw-env' },
+ () => ({
+ contents: getDevSwEnv()
+ }))
+ }
}]
})
const text = res.outputFiles[0].text
@@ -126,6 +150,30 @@ export const buildSwPlugin = ({
configFile: false
}
},
+ generateBundle: {
+ order: 'post',
+ sequential: true,
+ async handler (_, bundle) {
+ const assets = Object.keys(bundle)
+ .filter(name => !/\.map$/.test(name))
+ .map(name => '/' + name)
+ config.plugins.push({
+ name: 'build-sw-env-plugin',
+ resolveId (id) {
+ if (id === swEnvName) {
+ return swEnvNameResolved
+ }
+ return null
+ },
+ load (id) {
+ if (id === swEnvNameResolved) {
+ return getProdSwEnv({ assets })
+ }
+ return null
+ }
+ })
+ }
+ },
closeBundle: {
order: 'post',
sequential: true,
diff --git a/changelog.d/sw-cache-assets.add b/changelog.d/sw-cache-assets.add
new file mode 100644
index 000000000..5f7414eee
--- /dev/null
+++ b/changelog.d/sw-cache-assets.add
@@ -0,0 +1 @@
+Cache assets and emojis with service worker
diff --git a/src/App.js b/src/App.js
index 013ea323c..a251682dc 100644
--- a/src/App.js
+++ b/src/App.js
@@ -14,6 +14,7 @@ import EditStatusModal from './components/edit_status_modal/edit_status_modal.vu
import PostStatusModal from './components/post_status_modal/post_status_modal.vue'
import StatusHistoryModal from './components/status_history_modal/status_history_modal.vue'
import GlobalNoticeList from './components/global_notice_list/global_notice_list.vue'
+import { getOrCreateServiceWorker } from './services/sw/sw'
import { windowWidth, windowHeight } from './services/window_utils/window_utils'
import { mapGetters } from 'vuex'
import { defineAsyncComponent } from 'vue'
@@ -77,6 +78,7 @@ export default {
this.setThemeBodyClass()
this.removeSplash()
}
+ getOrCreateServiceWorker()
},
unmounted () {
window.removeEventListener('resize', this.updateMobileState)
diff --git a/src/components/settings_modal/tabs/general_tab.js b/src/components/settings_modal/tabs/general_tab.js
index 517f54eb1..df28a23e3 100644
--- a/src/components/settings_modal/tabs/general_tab.js
+++ b/src/components/settings_modal/tabs/general_tab.js
@@ -8,6 +8,7 @@ import InterfaceLanguageSwitcher from 'src/components/interface_language_switche
import SharedComputedObject from '../helpers/shared_computed_object.js'
import ProfileSettingIndicator from '../helpers/profile_setting_indicator.vue'
+import { clearCache, cacheKey, emojiCacheKey } from 'src/services/sw/sw.js'
import { library } from '@fortawesome/fontawesome-svg-core'
import {
faGlobe
@@ -98,6 +99,21 @@ const GeneralTab = {
methods: {
changeDefaultScope (value) {
this.$store.dispatch('setProfileOption', { name: 'defaultScope', value })
+ },
+ clearCache (key) {
+ clearCache(key)
+ .then(() => {
+ this.$store.dispatch('settingsSaved', { success: true })
+ })
+ .catch(error => {
+ this.$store.dispatch('settingsSaved', { error })
+ })
+ },
+ clearAssetCache () {
+ this.clearCache(cacheKey)
+ },
+ clearEmojiCache () {
+ this.clearCache(emojiCacheKey)
}
}
}
diff --git a/src/components/settings_modal/tabs/general_tab.vue b/src/components/settings_modal/tabs/general_tab.vue
index 02396114f..b8da782fb 100644
--- a/src/components/settings_modal/tabs/general_tab.vue
+++ b/src/components/settings_modal/tabs/general_tab.vue
@@ -509,6 +509,29 @@
+
+
{{ $t('settings.cache') }}
+
+ -
+
+
+ -
+
+
+
+
diff --git a/src/i18n/en.json b/src/i18n/en.json
index 14f36844b..45c52ce67 100644
--- a/src/i18n/en.json
+++ b/src/i18n/en.json
@@ -1091,7 +1091,10 @@
"reset_value": "Reset",
"reset_value_tooltip": "Reset draft",
"hard_reset_value": "Hard reset",
- "hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value"
+ "hard_reset_value_tooltip": "Remove setting from storage, forcing use of default value",
+ "cache": "Cache",
+ "clear_asset_cache": "Clear asset cache",
+ "clear_emoji_cache": "Clear emoji cache"
},
"admin_dash": {
"window_title": "Administration",
diff --git a/src/services/sw/sw.js b/src/services/sw/sw.js
index 2133db222..a986e98dd 100644
--- a/src/services/sw/sw.js
+++ b/src/services/sw/sw.js
@@ -146,3 +146,11 @@ export function unregisterPushNotifications (token) {
]).catch((e) => console.warn(`Failed to disable Web Push Notifications: ${e.message}`))
}
}
+
+export const shouldCache = process.env.NODE_ENV === 'production'
+export const cacheKey = 'pleroma-fe'
+export const emojiCacheKey = 'pleroma-fe-emoji'
+
+export const clearCache = (key) => caches.delete(key)
+
+export { getOrCreateServiceWorker }
diff --git a/src/sw.js b/src/sw.js
index 3c53d5339..834c68e7f 100644
--- a/src/sw.js
+++ b/src/sw.js
@@ -1,8 +1,10 @@
/* eslint-env serviceworker */
+import 'virtual:pleroma-fe/service_worker_env'
import { storage } from 'src/lib/storage.js'
import { parseNotification } from './services/entity_normalizer/entity_normalizer.service.js'
import { prepareNotificationObject } from './services/notification_utils/notification_utils.js'
+import { shouldCache, cacheKey, emojiCacheKey } from './services/sw/sw.js'
import { createI18n } from 'vue-i18n'
// Collects all messages for service workers
// Needed because service workers cannot use dynamic imports
@@ -85,6 +87,80 @@ const showPushNotification = async (event) => {
return Promise.resolve()
}
+const cacheFiles = self.serviceWorkerOption.assets
+const isEmoji = req => {
+ if (req.method !== 'GET') {
+ return false
+ }
+ const url = new URL(req.url)
+
+ return url.pathname.startsWith('/emoji/')
+}
+const isNotMedia = req => {
+ if (req.method !== 'GET') {
+ return false
+ }
+ const url = new URL(req.url)
+ return !url.pathname.startsWith('/media/')
+}
+const isAsset = req => {
+ const url = new URL(req.url)
+ return cacheFiles.includes(url.pathname)
+}
+
+const isSuccessful = (resp) => {
+ if (!resp.ok) {
+ return false
+ }
+ if ((new URL(resp.url)).pathname === '/index.html') {
+ // For index.html itself, there is no fallback possible.
+ return true
+ }
+ const type = resp.headers.get('Content-Type')
+ // Backend will revert to index.html if the file does not exist, so text/html for emojis and assets is a failure
+ return type && !type.includes('text/html')
+}
+
+self.addEventListener('install', async (event) => {
+ if (shouldCache) {
+ event.waitUntil((async () => {
+ // Do not preload i18n and emoji annotations to speed up loading
+ const shouldPreload = (route) => {
+ return !route.startsWith('/static/js/i18n/') && !route.startsWith('/static/js/emoji-annotations/')
+ }
+ const cache = await caches.open(cacheKey)
+ await Promise.allSettled(cacheFiles.filter(shouldPreload).map(async (route) => {
+ // https://developer.mozilla.org/en-US/docs/Web/API/Cache/add
+ // originally we used addAll() but it will raise a problem in one edge case:
+ // when the file for the route is not found, backend will return index.html with code 200
+ // but it's wrong, and it's cached, so we end up with a bad cache.
+ // this can happen when you refresh when you are in the process of upgrading
+ // the frontend.
+ const resp = await fetch(route)
+ if (isSuccessful(resp)) {
+ await cache.put(route, resp)
+ }
+ }))
+ })())
+ }
+})
+
+self.addEventListener('activate', async (event) => {
+ if (shouldCache) {
+ event.waitUntil((async () => {
+ const cache = await caches.open(cacheKey)
+ const keys = await cache.keys()
+ await Promise.all(
+ keys.filter(request => {
+ const url = new URL(request.url)
+ const shouldKeep = cacheFiles.includes(url.pathname)
+ return !shouldKeep
+ }).map(k => cache.delete(k))
+ )
+ })())
+ }
+})
+
self.addEventListener('push', async (event) => {
if (event.data) {
// Supposedly, we HAVE to return a promise inside waitUntil otherwise it will
@@ -143,4 +219,35 @@ self.addEventListener('notificationclick', (event) => {
}))
})
-console.log('sw here')
+self.addEventListener('fetch', (event) => {
+ // Do not mess up with remote things
+ const isSameOrigin = (new URL(event.request.url)).origin === self.location.origin
+ if (shouldCache && event.request.method === 'GET' && isSameOrigin && isNotMedia(event.request)) {
+ // this is a bit spammy
+ // console.debug('[Service worker] fetch:', event.request.url)
+ event.respondWith((async () => {
+ const r = await caches.match(event.request)
+ const isEmojiReq = isEmoji(event.request)
+
+ if (r && isSuccessful(r)) {
+ console.debug('[Service worker] already cached:', event.request.url)
+ return r
+ }
+
+ try {
+ const response = await fetch(event.request)
+ if (response.ok &&
+ isSuccessful(response) &&
+ (isEmojiReq || isAsset(event.request))) {
+ console.debug(`[Service worker] caching ${isEmojiReq ? 'emoji' : 'asset'}:`, event.request.url)
+ const cache = await caches.open(isEmojiReq ? emojiCacheKey : cacheKey)
+ await cache.put(event.request.clone(), response.clone())
+ }
+ return response
+ } catch (e) {
+ console.error('[Service worker] error when caching emoji:', e)
+ throw e
+ }
+ })())
+ }
+})
diff --git a/vite.config.js b/vite.config.js
index 429fd0686..0b4d0db90 100644
--- a/vite.config.js
+++ b/vite.config.js
@@ -172,7 +172,14 @@ export default defineConfig(async ({ mode, command }) => {
return 'static/js/[name].[hash].js'
}
},
- chunkFileNames () {
+ chunkFileNames (chunkInfo) {
+ if (chunkInfo.facadeModuleId) {
+ if (chunkInfo.facadeModuleId.includes('node_modules/@kazvmoe-infra/unicode-emoji-json/annotations/')) {
+ return 'static/js/emoji-annotations/[name].[hash].js'
+ } else if (chunkInfo.facadeModuleId.includes('src/i18n/')) {
+ return 'static/js/i18n/[name].[hash].js'
+ }
+ }
return 'static/js/[name].[hash].js'
},
assetFileNames (assetInfo) {