350 lines
11 KiB
TypeScript
350 lines
11 KiB
TypeScript
import { clientsClaim } from 'workbox-core';
|
|
import type {
|
|
WorkboxPlugin,
|
|
CacheDidUpdateCallbackParam,
|
|
CacheKeyWillBeUsedCallbackParam,
|
|
CacheWillUpdateCallbackParam,
|
|
CachedResponseWillBeUsedCallbackParam
|
|
} from 'workbox-core/types';
|
|
import { cleanupOutdatedCaches, precacheAndRoute } from 'workbox-precaching';
|
|
import { registerRoute } from 'workbox-routing';
|
|
import { CacheFirst, NetworkFirst, StaleWhileRevalidate } from 'workbox-strategies';
|
|
|
|
interface CacheTimestampEntry {
|
|
request: Request;
|
|
time: number;
|
|
}
|
|
|
|
class DynamicExpirationPlugin implements WorkboxPlugin {
|
|
private static readonly SW_CACHE_TIME_HEADER: string = 'ezbookkeeping-sw-cache-time';
|
|
private maxEntries: number;
|
|
private maxAgeMilliseconds: number;
|
|
private cleaningCache: boolean = false;
|
|
|
|
constructor(maxEntries: number, maxAgeMilliseconds: number) {
|
|
this.maxEntries = maxEntries;
|
|
this.maxAgeMilliseconds = maxAgeMilliseconds;
|
|
}
|
|
|
|
public getMaxEntries(): number {
|
|
return this.maxEntries;
|
|
}
|
|
|
|
public setMaxEntries(maxEntries: number): void {
|
|
this.maxEntries = maxEntries;
|
|
}
|
|
|
|
public getMaxAgeMilliseconds(): number {
|
|
return this.maxAgeMilliseconds;
|
|
}
|
|
|
|
public setMaxAgeMilliseconds(maxAgeMilliseconds: number): void {
|
|
this.maxAgeMilliseconds = maxAgeMilliseconds;
|
|
}
|
|
|
|
public async cacheWillUpdate(param: CacheWillUpdateCallbackParam): Promise<Response | null> {
|
|
const response = param.response;
|
|
|
|
if (!response || response.status < 200 || response.status >= 300 || response.type === 'opaque') {
|
|
return null;
|
|
}
|
|
|
|
const body = await response.blob();
|
|
const headers = new Headers(response.headers);
|
|
headers.set(DynamicExpirationPlugin.SW_CACHE_TIME_HEADER, Date.now().toString());
|
|
|
|
return new Response(body, {
|
|
status: response.status,
|
|
statusText: response.statusText,
|
|
headers: headers
|
|
});
|
|
}
|
|
|
|
public async cachedResponseWillBeUsed(param: CachedResponseWillBeUsedCallbackParam): Promise<Response | null> {
|
|
const cachedResponse = param.cachedResponse;
|
|
|
|
if (!cachedResponse) {
|
|
return null;
|
|
}
|
|
|
|
const cacheTime: string | null = cachedResponse.headers.get(DynamicExpirationPlugin.SW_CACHE_TIME_HEADER);
|
|
|
|
if (!cacheTime) {
|
|
return cachedResponse;
|
|
}
|
|
|
|
const age: number = Date.now() - Number(cacheTime);
|
|
|
|
if (this.maxAgeMilliseconds > 0 && age >= this.maxAgeMilliseconds) {
|
|
if (param.cacheName) {
|
|
const cache = await caches.open(param.cacheName);
|
|
await cache.delete(param.request);
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
return cachedResponse;
|
|
}
|
|
|
|
public async cacheDidUpdate(param: CacheDidUpdateCallbackParam): Promise<void> {
|
|
if (this.cleaningCache || !param.cacheName) {
|
|
return;
|
|
}
|
|
|
|
this.cleaningCache = true;
|
|
|
|
const cache: Cache = await caches.open(param.cacheName);
|
|
const requests: readonly Request[] = await cache.keys();
|
|
|
|
if (requests.length <= this.maxEntries) {
|
|
this.cleaningCache = false;
|
|
return;
|
|
}
|
|
|
|
const entries: CacheTimestampEntry[] = [];
|
|
|
|
for (const request of requests) {
|
|
const response: Response | undefined = await cache.match(request);
|
|
|
|
if (!response) {
|
|
continue;
|
|
}
|
|
|
|
const cacheTime: string | null = response.headers.get(DynamicExpirationPlugin.SW_CACHE_TIME_HEADER);
|
|
let time: number = cacheTime ? Number(cacheTime) : 0;
|
|
|
|
if (Number.isFinite(time)) {
|
|
const age: number = Date.now() - time;
|
|
|
|
if (this.maxAgeMilliseconds > 0 && age >= this.maxAgeMilliseconds) {
|
|
await cache.delete(request);
|
|
continue;
|
|
}
|
|
} else {
|
|
time = 0;
|
|
}
|
|
|
|
entries.push({
|
|
request: request,
|
|
time: time
|
|
});
|
|
}
|
|
|
|
if (entries.length <= this.maxEntries) {
|
|
this.cleaningCache = false;
|
|
return;
|
|
}
|
|
|
|
entries.sort((a, b) => a.time - b.time);
|
|
|
|
const removeCount: number = entries.length - this.maxEntries;
|
|
|
|
for (let i = 0; i < removeCount; i++) {
|
|
const entry = entries[i];
|
|
|
|
if (entry && entry.request) {
|
|
await cache.delete(entry.request);
|
|
}
|
|
}
|
|
|
|
this.cleaningCache = false;
|
|
}
|
|
}
|
|
|
|
class MapDataRequestStripTokenPlugin implements WorkboxPlugin {
|
|
public async cacheKeyWillBeUsed(param: CacheKeyWillBeUsedCallbackParam): Promise<Request> {
|
|
const url = new URL(param.request.url);
|
|
|
|
if (url.searchParams.has('token')) {
|
|
url.searchParams.delete('token');
|
|
return new Request(url.href, param.request);
|
|
}
|
|
|
|
return param.request;
|
|
}
|
|
}
|
|
|
|
interface MapCacheConfig {
|
|
enabled: boolean;
|
|
patterns: RegExp[];
|
|
mapDataRequestStripTokenPlugin: MapDataRequestStripTokenPlugin;
|
|
expirationPlugin: DynamicExpirationPlugin;
|
|
}
|
|
|
|
declare const self: ServiceWorkerGlobalScope;
|
|
|
|
const SW_ASSETS_CACHE_NAME: string = 'ezbookkeeping-assets-cache';
|
|
const SW_CODE_CACHE_NAME: string = 'ezbookkeeping-code-cache';
|
|
const SW_MAP_CACHE_NAME: string = 'ezbookkeeping-map-cache';
|
|
const SW_SHARE_CACHE_NAME: string = 'ezbookkeeping-share-cache';
|
|
const SW_SHARE_IMAGE_URL_PATHNAME: string = '__share__image__';
|
|
const SW_SHARE_IMAGE_PARAM_NAME: string = 'image';
|
|
|
|
const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG: string = 'UPDATE_MAP_CACHE_CONFIG';
|
|
const SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE: string = 'UPDATE_MAP_CACHE_CONFIG_RESPONSE';
|
|
|
|
const DEFAULT_MAP_CACHE_MAX_ENTRIES: number = 1000;
|
|
const DEFAULT_MAP_CACHE_MAX_AGE_MILLISECONDS: number = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
const mapCacheConfig: MapCacheConfig = {
|
|
enabled: false,
|
|
patterns: [],
|
|
mapDataRequestStripTokenPlugin: new MapDataRequestStripTokenPlugin(),
|
|
expirationPlugin: new DynamicExpirationPlugin(DEFAULT_MAP_CACHE_MAX_ENTRIES, DEFAULT_MAP_CACHE_MAX_AGE_MILLISECONDS)
|
|
};
|
|
|
|
self.skipWaiting();
|
|
clientsClaim();
|
|
precacheAndRoute(self.__WB_MANIFEST);
|
|
cleanupOutdatedCaches();
|
|
|
|
registerRoute(
|
|
/.*\/img\/desktop\/.*\.(png|jpg|jpeg|gif|tiff|bmp|svg)/,
|
|
new StaleWhileRevalidate({
|
|
cacheName: SW_ASSETS_CACHE_NAME,
|
|
})
|
|
);
|
|
|
|
registerRoute(
|
|
/.*\/fonts\/.*\.(eot|ttf|svg|woff)/,
|
|
new CacheFirst({
|
|
cacheName: SW_ASSETS_CACHE_NAME,
|
|
})
|
|
);
|
|
|
|
registerRoute(
|
|
/.*\/(mobile|mobile\/|desktop|desktop\/)$/,
|
|
new NetworkFirst({
|
|
cacheName: SW_CODE_CACHE_NAME,
|
|
})
|
|
);
|
|
|
|
registerRoute(
|
|
/.*\/(mobile|mobile\/)#!\//,
|
|
new NetworkFirst({
|
|
cacheName: SW_CODE_CACHE_NAME,
|
|
})
|
|
);
|
|
|
|
registerRoute(
|
|
/.*\/(desktop|desktop\/)#\//,
|
|
new NetworkFirst({
|
|
cacheName: SW_CODE_CACHE_NAME,
|
|
})
|
|
);
|
|
|
|
registerRoute(
|
|
/.*\/(index\.html|mobile\.html|desktop\.html)/,
|
|
new NetworkFirst({
|
|
cacheName: SW_CODE_CACHE_NAME,
|
|
})
|
|
);
|
|
|
|
registerRoute(
|
|
/.*\/css\/.*\.css/,
|
|
new CacheFirst({
|
|
cacheName: SW_CODE_CACHE_NAME,
|
|
})
|
|
);
|
|
|
|
registerRoute(
|
|
/.*\/js\/.*\.js/,
|
|
new CacheFirst({
|
|
cacheName: SW_CODE_CACHE_NAME,
|
|
})
|
|
);
|
|
|
|
registerRoute(
|
|
({ url }) => {
|
|
if (!mapCacheConfig.enabled || mapCacheConfig.patterns.length < 1) {
|
|
return false;
|
|
}
|
|
|
|
for (const pattern of mapCacheConfig.patterns) {
|
|
if (pattern.test && pattern.test(url.href)) {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
return false;
|
|
},
|
|
new CacheFirst({
|
|
cacheName: SW_MAP_CACHE_NAME,
|
|
plugins: [
|
|
mapCacheConfig.mapDataRequestStripTokenPlugin,
|
|
mapCacheConfig.expirationPlugin
|
|
]
|
|
})
|
|
);
|
|
|
|
self.addEventListener('fetch', (event: FetchEvent) => {
|
|
const request: Request = event.request;
|
|
|
|
if (request.method !== 'POST' || !request.url.endsWith(SW_SHARE_IMAGE_URL_PATHNAME)) {
|
|
return;
|
|
}
|
|
|
|
event.respondWith((async (): Promise<Response> => {
|
|
let redirectUrl = request.url;
|
|
const lastShareIndex = redirectUrl.lastIndexOf(SW_SHARE_IMAGE_URL_PATHNAME);
|
|
redirectUrl = redirectUrl.substring(0, lastShareIndex);
|
|
|
|
try {
|
|
const formData = await request.formData();
|
|
const image = formData.get(SW_SHARE_IMAGE_PARAM_NAME);
|
|
|
|
if (image instanceof Blob) {
|
|
const cache: Cache = await caches.open(SW_SHARE_CACHE_NAME);
|
|
const response = new Response(image, {
|
|
headers: {
|
|
'Content-Type': image.type
|
|
}
|
|
});
|
|
|
|
const putPromise = cache.put(SW_SHARE_CACHE_NAME, response.clone());
|
|
event.waitUntil(putPromise);
|
|
await putPromise;
|
|
}
|
|
|
|
return Response.redirect(redirectUrl, 303);
|
|
} catch (ex) {
|
|
console.error('failed to handle share image upload in service worker', ex);
|
|
return Response.redirect(redirectUrl, 303);
|
|
}
|
|
})());
|
|
});
|
|
|
|
self.addEventListener('message', (event: ExtendableMessageEvent) => {
|
|
try {
|
|
if (event.data && event.data.type === SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG && 'payload' in event.data) {
|
|
mapCacheConfig.enabled = !!event.data.payload['enabled'];
|
|
mapCacheConfig.patterns = [];
|
|
mapCacheConfig.expirationPlugin.setMaxEntries(event.data.payload['maxEntries'] ?? DEFAULT_MAP_CACHE_MAX_ENTRIES);
|
|
mapCacheConfig.expirationPlugin.setMaxAgeMilliseconds(event.data.payload['maxAgeMilliseconds'] ?? DEFAULT_MAP_CACHE_MAX_AGE_MILLISECONDS);
|
|
|
|
if (event.data.payload['patterns'] && Array.isArray(event.data.payload['patterns'])) {
|
|
for (const pattern of event.data.payload['patterns']) {
|
|
if (pattern) {
|
|
mapCacheConfig.patterns.push(new RegExp(pattern as string));
|
|
}
|
|
}
|
|
}
|
|
|
|
if (event.ports && event.ports[0] && typeof event.ports[0].postMessage === 'function') {
|
|
event.ports[0].postMessage({
|
|
type: SW_MESSAGE_TYPE_UPDATE_MAP_CACHE_CONFIG_RESPONSE,
|
|
payload: {
|
|
enabled: mapCacheConfig.enabled,
|
|
patterns: event.data.payload['patterns'],
|
|
maxEntries: mapCacheConfig.expirationPlugin.getMaxEntries(),
|
|
maxAgeMilliseconds: mapCacheConfig.expirationPlugin.getMaxAgeMilliseconds()
|
|
}
|
|
});
|
|
}
|
|
}
|
|
} catch (ex) {
|
|
console.error('failed to process message in service worker', ex);
|
|
}
|
|
});
|