Files
typedserver/ts_web_serviceworker/classes.cachemanager.ts

265 lines
11 KiB
TypeScript

import * as plugins from './plugins.js';
import * as interfaces from './env.js';
import { logger } from './logging.js';
import { ServiceWorker } from './classes.serviceworker.js';
export class CacheManager {
public losslessServiceWorkerRef: ServiceWorker;
public usedCacheNames = {
runtimeCacheName: 'runtime'
};
constructor(losslessServiceWorkerRefArg: ServiceWorker) {
this.losslessServiceWorkerRef = losslessServiceWorkerRefArg;
this._setupCache();
}
/**
* Sets up the service worker's fetch event to intercept and cache responses.
*/
private _setupCache = () => {
// Create a matching request. For internal requests, reuse the original; for external requests, create one with CORS settings.
const createMatchRequest = (requestArg: Request): Request => {
let matchRequest: Request;
try {
if (
requestArg.url.startsWith(
this.losslessServiceWorkerRef.serviceWindowRef.location.origin
)
) {
// Internal request
matchRequest = requestArg;
} else {
// External request: create a new Request with appropriate CORS settings.
matchRequest = new Request(requestArg.url, {
method: requestArg.method,
headers: requestArg.headers,
mode: 'cors',
credentials: 'same-origin',
redirect: 'follow'
});
}
} catch (err) {
logger.log('error', `Error creating match request for ${requestArg.url}: ${err}`);
throw err;
}
return matchRequest;
};
/**
* Creates a 500 error response.
*/
const create500Response = async (requestArg: Request, responseArg: Response): Promise<Response> => {
try {
const responseText = await responseArg.clone().text();
return new Response(
`
<html>
<head>
<style>
.note {
padding: 10px;
color: #fff;
background: #000;
border-bottom: 1px solid #e4002b;
margin-bottom: 20px;
}
</style>
</head>
<body>
<div class="note">
<strong>ServiceWorker error 500</strong><br>
</div>
ServiceWorker is unable to fetch this request.<br>
<br>
<strong>Request URL:</strong> ${requestArg.url}<br>
<strong>Response Type:</strong> ${responseArg.type}<br>
<strong>Response Body:</strong> ${responseText}<br>
</body>
</html>
`,
{
headers: { 'Content-Type': 'text/html' },
status: 500
}
);
} catch (err) {
logger.log('error', `Error creating 500 response for ${requestArg.url}: ${err}`);
return new Response('Internal error', { status: 500 });
}
};
// Listen for fetch events on the service worker's controlled window.
this.losslessServiceWorkerRef.serviceWindowRef.addEventListener('fetch', async (fetchEventArg: any) => {
try {
const originalRequest: Request = fetchEventArg.request;
const parsedUrl = new URL(originalRequest.url);
// Block requests that we don't want the service worker to handle.
if (
parsedUrl.hostname.includes('paddle.com') ||
parsedUrl.hostname.includes('paypal.com') ||
parsedUrl.hostname.includes('reception.lossless.one') ||
parsedUrl.pathname.startsWith('/socket.io') ||
originalRequest.url.startsWith('https://umami.')
) {
logger.log('note', `ServiceWorker not active for ${parsedUrl.toString()}`);
return;
}
// Create a deferred promise for the fetch event's response.
const done = plugins.smartpromise.defer<Response>();
fetchEventArg.respondWith(done.promise);
// Determine whether this request should be cached.
if (
(originalRequest.method === 'GET' &&
originalRequest.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin) &&
!originalRequest.url.includes('/api/') &&
!originalRequest.url.includes('smartserve/reloadcheck')) ||
originalRequest.url.includes('https://assetbroker.') ||
originalRequest.url.includes('https://unpkg.com') ||
originalRequest.url.includes('https://fonts.googleapis.com') ||
originalRequest.url.includes('https://fonts.gstatic.com')
) {
// Kick off an asynchronous update check.
this.losslessServiceWorkerRef.updateManager.checkUpdate(this);
const matchRequest = createMatchRequest(originalRequest);
const cachedResponse = await caches.match(matchRequest);
if (cachedResponse) {
logger.log('ok', `CACHED: Found cached response for ${matchRequest.url}`);
done.resolve(cachedResponse);
return;
}
logger.log('info', `NOTYETCACHED: Trying to cache ${matchRequest.url}`);
let newResponse: Response;
try {
newResponse = await fetch(matchRequest);
} catch (err: any) {
logger.log('error', `Fetch error for ${matchRequest.url}: ${err}`);
newResponse = await create500Response(matchRequest, new Response(err.message));
}
// Check if the response should be cached. In this version, if the response status is >299 or the response is opaque, we do not cache.
if (newResponse.status > 299 || newResponse.type === 'opaque') {
logger.log(
'error',
`NOTCACHED: Can't cache response for ${matchRequest.url} (status: ${newResponse.status}, type: ${newResponse.type})`
);
// Optionally, you can force a 500 response so errors are clearly visible.
done.resolve(await create500Response(matchRequest, newResponse));
} else {
try {
const cache = await caches.open(this.usedCacheNames.runtimeCacheName);
const responseToPutToCache = newResponse.clone();
// Create new headers preserving all except caching-related ones.
const headers = new Headers();
responseToPutToCache.headers.forEach((value, key) => {
if (!['Cache-Control', 'cache-control', 'Expires', 'expires', 'Pragma', 'pragma'].includes(key)) {
headers.set(key, value);
}
});
// Ensure that CORS-related headers are present.
if (!headers.has('Access-Control-Allow-Origin')) {
headers.set('Access-Control-Allow-Origin', '*');
}
if (!headers.has('Access-Control-Allow-Methods')) {
headers.set('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
}
if (!headers.has('Access-Control-Allow-Headers')) {
headers.set('Access-Control-Allow-Headers', 'Content-Type');
}
// Set Cross-Origin-Resource-Policy
if (matchRequest.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin)) {
// For same-origin resources
headers.set('Cross-Origin-Resource-Policy', 'same-origin');
} else {
// For cross-origin resources that we explicitly allow
headers.set('Cross-Origin-Resource-Policy', 'cross-origin');
}
// Set caching headers - use modern Cache-Control only
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
// IMPORTANT: Read the full response body as a blob to avoid issues (e.g., Safari locked streams).
const bodyBlob = await responseToPutToCache.blob();
const newCachedResponse = new Response(bodyBlob, {
status: responseToPutToCache.status,
statusText: responseToPutToCache.statusText,
headers
});
await cache.put(matchRequest, newCachedResponse);
logger.log('ok', `NOWCACHED: Cached response for ${matchRequest.url} for subsequent requests!`);
done.resolve(newResponse);
} catch (err) {
logger.log('error', `Error caching response for ${matchRequest.url}: ${err}`);
done.resolve(await create500Response(matchRequest, newResponse));
}
}
} else {
// For requests not intended for caching, simply fetch from the origin.
logger.log('ok', `NOTCACHED: Not caching ${originalRequest.url}. Fetching from origin...`);
try {
const originResponse = await fetch(originalRequest);
done.resolve(originResponse);
} catch (err: any) {
logger.log('error', `Fetch error for ${originalRequest.url}: ${err}`);
done.resolve(await create500Response(originalRequest, new Response(err.message)));
}
}
} catch (err) {
logger.log('error', `Unhandled fetch event error: ${err}`);
}
});
};
/**
* Cleans all caches.
* Should only be run when a new ServiceWorker is activated.
*/
public cleanCaches = async (reasonArg = 'no reason given'): Promise<void> => {
try {
logger.log('info', `MAJOR CACHEEVENT: Cleaning caches now! Reason: ${reasonArg}`);
const cacheNames = await caches.keys();
const deletePromises = cacheNames.map((cacheToDelete) =>
caches.delete(cacheToDelete).then(() => {
logger.log('ok', `Deleted cache ${cacheToDelete}`);
})
);
await Promise.all(deletePromises);
} catch (err) {
logger.log('error', `Error cleaning caches: ${err}`);
}
};
/**
* Revalidates the runtime cache by fetching fresh responses and updating the cache.
*/
public async revalidateCache(): Promise<void> {
try {
const runtimeCache = await caches.open(this.usedCacheNames.runtimeCacheName);
const cacheKeys = await runtimeCache.keys();
for (const requestArg of cacheKeys) {
try {
const clonedRequest = requestArg.clone();
const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000);
if (response && response.status >= 200 && response.status < 300) {
await runtimeCache.delete(requestArg);
await runtimeCache.put(requestArg, response);
}
} catch (err) {
logger.log('error', `Error revalidating cache for ${requestArg.url}: ${err}`);
}
}
} catch (err) {
logger.log('error', `Error revalidating runtime cache: ${err}`);
}
}
}