kopia lustrzana https://github.com/cheeaun/phanpy
366 wiersze
10 KiB
JavaScript
366 wiersze
10 KiB
JavaScript
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
|
|
import { ExpirationPlugin } from 'workbox-expiration';
|
|
import * as navigationPreload from 'workbox-navigation-preload';
|
|
import { RegExpRoute, registerRoute, Route } from 'workbox-routing';
|
|
import {
|
|
CacheFirst,
|
|
NetworkFirst,
|
|
StaleWhileRevalidate,
|
|
} from 'workbox-strategies';
|
|
|
|
navigationPreload.enable();
|
|
|
|
self.__WB_DISABLE_DEV_LOGS = true;
|
|
|
|
// Custom plugin to manage hashed assets
|
|
class AssetHashPlugin {
|
|
constructor(options = {}) {
|
|
this.maxHashes = options.maxHashes || 2;
|
|
this.dbName = 'workbox-expiration';
|
|
this.storeName = 'cache-entries';
|
|
}
|
|
|
|
// Extract base filename from a hashed URL
|
|
// e.g., "main-abc123.js" -> "main"
|
|
_getBaseName(url) {
|
|
const urlObj = new URL(url);
|
|
const pathname = urlObj.pathname;
|
|
const filename = pathname.split('/').pop();
|
|
|
|
// Match pattern: basename-hash.extension
|
|
// Hash uses URL-safe base64 characters (A-Za-z0-9_-), typically 8+ chars
|
|
const match = filename.match(/^(.+?)-[A-Za-z0-9_-]{8,}\.(js|css)$/);
|
|
return match ? match[1] : null;
|
|
}
|
|
|
|
// Get timestamps for multiple URLs from Workbox's ExpirationPlugin IndexedDB
|
|
async _getTimestampsFromDB(cacheName, urls) {
|
|
try {
|
|
const db = await new Promise((resolve, reject) => {
|
|
const request = indexedDB.open(this.dbName);
|
|
request.onsuccess = () => resolve(request.result);
|
|
request.onerror = () => reject(request.error);
|
|
});
|
|
|
|
const tx = db.transaction(this.storeName, 'readonly');
|
|
const store = tx.objectStore(this.storeName);
|
|
|
|
// Batch read all timestamps in a single transaction
|
|
const timestamps = await Promise.all(
|
|
urls.map((url) => {
|
|
// Workbox stores entries with key format: `${cacheName}|${url}`
|
|
const key = `${cacheName}|${url}`;
|
|
|
|
return new Promise((resolve) => {
|
|
const request = store.get(key);
|
|
request.onsuccess = () =>
|
|
resolve(request.result?.timestamp || Date.now());
|
|
request.onerror = () => resolve(Date.now());
|
|
});
|
|
}),
|
|
);
|
|
|
|
db.close();
|
|
|
|
return timestamps;
|
|
} catch (error) {
|
|
console.warn(
|
|
`[AssetHashPlugin] Error reading timestamps from IndexedDB:`,
|
|
error,
|
|
);
|
|
// Return current time for all URLs as fallback
|
|
return urls.map(() => Date.now());
|
|
}
|
|
}
|
|
|
|
cacheDidUpdate({ cacheName, request }) {
|
|
// Run cleanup asynchronously without blocking the cache operation
|
|
this._cleanupOldHashes(cacheName, request.url);
|
|
}
|
|
|
|
async _cleanupOldHashes(cacheName, requestUrl) {
|
|
try {
|
|
const baseName = this._getBaseName(requestUrl);
|
|
if (!baseName) return;
|
|
|
|
const cache = await caches.open(cacheName);
|
|
|
|
// Find all cached entries with the same base name
|
|
const cachedRequests = await cache.keys();
|
|
const matchingRequests = [];
|
|
|
|
for (const cachedRequest of cachedRequests) {
|
|
const cachedBaseName = this._getBaseName(cachedRequest.url);
|
|
if (cachedBaseName === baseName) {
|
|
const response = await cache.match(cachedRequest);
|
|
if (response) {
|
|
matchingRequests.push(cachedRequest);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (matchingRequests.length <= this.maxHashes) return;
|
|
|
|
// Batch read all timestamps in a single database transaction
|
|
const urls = matchingRequests.map((req) => req.url);
|
|
const timestamps = await this._getTimestampsFromDB(cacheName, urls);
|
|
|
|
// Build matching entries with timestamps
|
|
const matchingEntries = matchingRequests.map((req, index) => ({
|
|
request: req,
|
|
url: req.url,
|
|
timestamp: timestamps[index],
|
|
}));
|
|
|
|
// Sort by timestamp (newest first)
|
|
matchingEntries.sort((a, b) => b.timestamp - a.timestamp);
|
|
|
|
// Keep only the maxHashes most recent, delete the rest
|
|
const toDelete = matchingEntries.slice(this.maxHashes);
|
|
|
|
for (const entry of toDelete) {
|
|
await cache.delete(entry.request);
|
|
console.log(`[AssetHashPlugin] Deleted old hash: ${entry.url}`);
|
|
}
|
|
} catch (error) {
|
|
console.warn(`[AssetHashPlugin] Error during cleanup:`, error);
|
|
}
|
|
}
|
|
}
|
|
|
|
const expirationPluginOptions = {
|
|
purgeOnQuotaError: true,
|
|
// "CacheFirst image maxEntries not working" https://github.com/GoogleChrome/workbox/issues/2768#issuecomment-793109906
|
|
matchOptions: {
|
|
// https://developer.mozilla.org/en-US/docs/Web/API/Cache/delete#Parameters
|
|
ignoreVary: true,
|
|
},
|
|
};
|
|
|
|
const iconsRoute = new Route(
|
|
({ request, sameOrigin }) => {
|
|
const isIcon = request.url.includes('/icons/');
|
|
return sameOrigin && isIcon;
|
|
},
|
|
new CacheFirst({
|
|
cacheName: 'icons',
|
|
plugins: [
|
|
new ExpirationPlugin({
|
|
// Weirdly high maxEntries number, due to some old icons suddenly disappearing and not rendering
|
|
// NOTE: Temporary fix
|
|
maxEntries: 300,
|
|
maxAgeSeconds: 3 * 24 * 60 * 60, // 3 days
|
|
...expirationPluginOptions,
|
|
}),
|
|
new CacheableResponsePlugin({
|
|
statuses: [0, 200],
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
registerRoute(iconsRoute);
|
|
|
|
const assetsRoute = new Route(
|
|
({ request, sameOrigin }) => {
|
|
const isAsset =
|
|
request.destination === 'style' || request.destination === 'script';
|
|
const hasHash = /-[0-9a-z-]{4,}\./i.test(request.url);
|
|
return sameOrigin && isAsset && hasHash;
|
|
},
|
|
new NetworkFirst({
|
|
cacheName: 'assets',
|
|
networkTimeoutSeconds: 5,
|
|
plugins: [
|
|
// Only enable AssetHashPlugin in production
|
|
...(import.meta.env.PROD
|
|
? [
|
|
new AssetHashPlugin({
|
|
maxHashes: 2, // Keep only 2 most recent hashes of each file
|
|
}),
|
|
]
|
|
: []),
|
|
new ExpirationPlugin({
|
|
maxEntries: 40,
|
|
...expirationPluginOptions,
|
|
}),
|
|
new CacheableResponsePlugin({
|
|
statuses: [0, 200],
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
registerRoute(assetsRoute);
|
|
|
|
const imageRoute = new Route(
|
|
({ request, sameOrigin }) => {
|
|
const isRemote = !sameOrigin;
|
|
const isImage = request.destination === 'image';
|
|
const isAvatar = request.url.includes('/avatars/');
|
|
const isCustomEmoji = request.url.includes('/custom/_emojis');
|
|
const isEmoji = request.url.includes('/emoji/');
|
|
return isRemote && isImage && (isAvatar || isCustomEmoji || isEmoji);
|
|
},
|
|
new CacheFirst({
|
|
cacheName: 'remote-images',
|
|
plugins: [
|
|
new ExpirationPlugin({
|
|
maxEntries: 30,
|
|
...expirationPluginOptions,
|
|
}),
|
|
new CacheableResponsePlugin({
|
|
statuses: [0, 200],
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
registerRoute(imageRoute);
|
|
|
|
// 1-day cache for
|
|
// - /api/v1/custom_emojis
|
|
// - /api/v1/lists/:id
|
|
// - /api/v1/announcements
|
|
const apiExtendedRoute = new RegExpRoute(
|
|
/^https?:\/\/[^\/]+\/api\/v\d+\/(custom_emojis|lists\/\d+|announcements)$/,
|
|
new StaleWhileRevalidate({
|
|
cacheName: 'api-extended',
|
|
plugins: [
|
|
new ExpirationPlugin({
|
|
maxAgeSeconds: 12 * 60 * 60, // 12 hours
|
|
...expirationPluginOptions,
|
|
}),
|
|
new CacheableResponsePlugin({
|
|
statuses: [0, 200],
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
registerRoute(apiExtendedRoute);
|
|
|
|
// Note: expiration is not working as expected
|
|
// https://github.com/GoogleChrome/workbox/issues/3316
|
|
//
|
|
// const apiIntermediateRoute = new RegExpRoute(
|
|
// // Matches:
|
|
// // - trends/*
|
|
// // - timelines/link
|
|
// /^https?:\/\/[^\/]+\/api\/v\d+\/(trends|timelines\/link)/,
|
|
// new StaleWhileRevalidate({
|
|
// cacheName: 'api-intermediate',
|
|
// plugins: [
|
|
// new ExpirationPlugin({
|
|
// maxAgeSeconds: 1 * 60, // 1min
|
|
// }),
|
|
// new CacheableResponsePlugin({
|
|
// statuses: [0, 200],
|
|
// }),
|
|
// ],
|
|
// }),
|
|
// );
|
|
// registerRoute(apiIntermediateRoute);
|
|
|
|
const apiRoute = new RegExpRoute(
|
|
// Matches:
|
|
// - statuses/:id/context - some contexts are really huge
|
|
/^https?:\/\/[^\/]+\/api\/v\d+\/(statuses\/\d+\/context)/,
|
|
new NetworkFirst({
|
|
cacheName: 'api',
|
|
networkTimeoutSeconds: 5,
|
|
plugins: [
|
|
new ExpirationPlugin({
|
|
maxEntries: 30,
|
|
maxAgeSeconds: 5 * 60, // 5 minutes
|
|
...expirationPluginOptions,
|
|
}),
|
|
new CacheableResponsePlugin({
|
|
statuses: [0, 200],
|
|
}),
|
|
],
|
|
}),
|
|
);
|
|
registerRoute(apiRoute);
|
|
|
|
// PUSH NOTIFICATIONS
|
|
// ==================
|
|
|
|
self.addEventListener('push', (event) => {
|
|
const { data } = event;
|
|
if (data) {
|
|
const payload = data.json();
|
|
console.log('PUSH payload', payload);
|
|
const {
|
|
access_token,
|
|
title,
|
|
body,
|
|
icon,
|
|
notification_id,
|
|
notification_type,
|
|
preferred_locale,
|
|
} = payload;
|
|
|
|
if (!!navigator.setAppBadge) {
|
|
if (notification_type === 'mention') {
|
|
navigator.setAppBadge(1);
|
|
}
|
|
}
|
|
|
|
event.waitUntil(
|
|
self.registration.showNotification(title, {
|
|
body,
|
|
icon,
|
|
dir: 'auto',
|
|
badge: '/logo-badge-72.png',
|
|
lang: preferred_locale,
|
|
tag: notification_id,
|
|
timestamp: Date.now(),
|
|
data: {
|
|
access_token,
|
|
notification_type,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
self.addEventListener('notificationclick', (event) => {
|
|
const payload = event.notification;
|
|
console.log('NOTIFICATION CLICK payload', payload);
|
|
const { badge, body, data, dir, icon, lang, tag, timestamp, title } = payload;
|
|
const { access_token, notification_type } = data;
|
|
const url = `/#/notifications?id=${tag}&access_token=${btoa(access_token)}`;
|
|
|
|
event.waitUntil(
|
|
(async () => {
|
|
const clients = await self.clients.matchAll({
|
|
type: 'window',
|
|
includeUncontrolled: true,
|
|
});
|
|
console.log('NOTIFICATION CLICK clients 1', clients);
|
|
if (clients.length && 'navigate' in clients[0]) {
|
|
console.log('NOTIFICATION CLICK clients 2', clients);
|
|
const bestClient =
|
|
clients.find(
|
|
(client) => client.focused || client.visibilityState === 'visible',
|
|
) || clients[0];
|
|
console.log('NOTIFICATION CLICK navigate', url);
|
|
if (bestClient) {
|
|
console.log('NOTIFICATION CLICK postMessage', bestClient);
|
|
bestClient.focus();
|
|
bestClient.postMessage?.({
|
|
type: 'notification',
|
|
id: tag,
|
|
accessToken: access_token,
|
|
});
|
|
} else {
|
|
console.log('NOTIFICATION CLICK openWindow', url);
|
|
await self.clients.openWindow(url);
|
|
}
|
|
// }
|
|
} else {
|
|
console.log('NOTIFICATION CLICK openWindow', url);
|
|
await self.clients.openWindow(url);
|
|
}
|
|
await event.notification.close();
|
|
})(),
|
|
);
|
|
});
|