423 lines
17 KiB
TypeScript
423 lines
17 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';
|
|
import { getMetricsCollector } from './classes.metrics.js';
|
|
import { getEventBus, ServiceWorkerEvent } from './classes.eventbus.js';
|
|
import { getErrorHandler, ServiceWorkerErrorType } from './classes.errorhandler.js';
|
|
import { getDashboardGenerator } from './classes.dashboard.js';
|
|
|
|
export class CacheManager {
|
|
public losslessServiceWorkerRef: ServiceWorker;
|
|
|
|
public usedCacheNames = {
|
|
runtimeCacheName: 'runtime'
|
|
};
|
|
|
|
// Request deduplication: tracks in-flight requests to prevent duplicate fetches
|
|
private inFlightRequests: Map<string, Promise<Response>> = new Map();
|
|
private readonly INFLIGHT_CLEANUP_INTERVAL = 30000; // 30 seconds
|
|
private cleanupIntervalId: ReturnType<typeof setInterval> | null = null;
|
|
|
|
constructor(losslessServiceWorkerRefArg: ServiceWorker) {
|
|
this.losslessServiceWorkerRef = losslessServiceWorkerRefArg;
|
|
this._setupCache();
|
|
this._setupInFlightCleanup();
|
|
}
|
|
|
|
/**
|
|
* Sets up periodic cleanup of stale in-flight request entries
|
|
*/
|
|
private _setupInFlightCleanup(): void {
|
|
// Clean up stale entries periodically
|
|
this.cleanupIntervalId = setInterval(() => {
|
|
// The Map should naturally clean up via .finally(), but this is a safety net
|
|
if (this.inFlightRequests.size > 100) {
|
|
logger.log('warn', `In-flight requests map has ${this.inFlightRequests.size} entries, clearing...`);
|
|
this.inFlightRequests.clear();
|
|
}
|
|
}, this.INFLIGHT_CLEANUP_INTERVAL);
|
|
}
|
|
|
|
/**
|
|
* Fetches a request with deduplication - coalesces identical concurrent requests
|
|
*/
|
|
private async fetchWithDeduplication(request: Request): Promise<Response> {
|
|
const key = `${request.method}:${request.url}`;
|
|
const metrics = getMetricsCollector();
|
|
const eventBus = getEventBus();
|
|
|
|
// Check if we already have an in-flight request for this URL
|
|
const existingRequest = this.inFlightRequests.get(key);
|
|
if (existingRequest) {
|
|
logger.log('note', `Deduplicating request for ${request.url}`);
|
|
try {
|
|
const response = await existingRequest;
|
|
// Clone the response since it may have been consumed
|
|
return response.clone();
|
|
} catch (error) {
|
|
// If the original request failed, we should try again
|
|
this.inFlightRequests.delete(key);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
// Record the new request
|
|
metrics.recordRequest(request.url);
|
|
eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_START, {
|
|
url: request.url,
|
|
method: request.method,
|
|
});
|
|
|
|
const startTime = Date.now();
|
|
|
|
// Create a new fetch promise and track it
|
|
const fetchPromise = fetch(request)
|
|
.then(async (response) => {
|
|
const duration = Date.now() - startTime;
|
|
|
|
// Try to get response size
|
|
const contentLength = response.headers.get('content-length');
|
|
const bytes = contentLength ? parseInt(contentLength, 10) : 0;
|
|
|
|
metrics.recordRequestSuccess(request.url, duration, bytes);
|
|
eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_COMPLETE, {
|
|
url: request.url,
|
|
method: request.method,
|
|
status: response.status,
|
|
duration,
|
|
bytes,
|
|
});
|
|
|
|
return response;
|
|
})
|
|
.catch((error) => {
|
|
const duration = Date.now() - startTime;
|
|
const errorHandler = getErrorHandler();
|
|
|
|
errorHandler.handleNetworkError(
|
|
`Fetch failed for ${request.url}: ${error?.message || error}`,
|
|
request.url,
|
|
error instanceof Error ? error : undefined,
|
|
{ method: request.method, duration }
|
|
);
|
|
|
|
metrics.recordRequestFailure(request.url, error?.message);
|
|
eventBus.emit(ServiceWorkerEvent.NETWORK_REQUEST_ERROR, {
|
|
url: request.url,
|
|
method: request.method,
|
|
duration,
|
|
error: error?.message || 'Unknown error',
|
|
});
|
|
|
|
throw error;
|
|
})
|
|
.finally(() => {
|
|
// Remove from in-flight requests when done
|
|
this.inFlightRequests.delete(key);
|
|
});
|
|
|
|
// Track the in-flight request
|
|
this.inFlightRequests.set(key, fetchPromise);
|
|
|
|
return fetchPromise;
|
|
}
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Handle dashboard routes - serve directly from service worker
|
|
if (parsedUrl.pathname === '/sw-dash' || parsedUrl.pathname === '/sw-dash/') {
|
|
const dashboard = getDashboardGenerator();
|
|
fetchEventArg.respondWith(Promise.resolve(dashboard.serveDashboard()));
|
|
return;
|
|
}
|
|
// /sw-dash/metrics - THE initial seed endpoint (provides ALL data)
|
|
if (parsedUrl.pathname === '/sw-dash/metrics') {
|
|
const dashboard = getDashboardGenerator();
|
|
fetchEventArg.respondWith(dashboard.serveMetrics());
|
|
return;
|
|
}
|
|
// /sw-dash/speedtest - user-triggered speedtest
|
|
if (parsedUrl.pathname === '/sw-dash/speedtest') {
|
|
const dashboard = getDashboardGenerator();
|
|
fetchEventArg.respondWith(dashboard.runSpeedtest());
|
|
return;
|
|
}
|
|
// /sw-dash/resources - resource data (kept for now, could be merged into metrics)
|
|
if (parsedUrl.pathname === '/sw-dash/resources') {
|
|
const dashboard = getDashboardGenerator();
|
|
fetchEventArg.respondWith(Promise.resolve(dashboard.serveResources()));
|
|
return;
|
|
}
|
|
// All other /sw-dash/* routes removed - use DeesComms instead:
|
|
// - Events: via serviceworker_getEventLog, serviceworker_clearEventLog
|
|
// - Requests: via serviceworker_getTypedRequestLogs, serviceworker_clearTypedRequestLogs
|
|
|
|
// 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);
|
|
const metrics = getMetricsCollector();
|
|
const eventBus = getEventBus();
|
|
|
|
if (cachedResponse) {
|
|
// Record cache hit
|
|
const contentLength = cachedResponse.headers.get('content-length');
|
|
const bytes = contentLength ? parseInt(contentLength, 10) : 0;
|
|
const contentType = cachedResponse.headers.get('content-type') || 'unknown';
|
|
metrics.recordCacheHit(matchRequest.url, bytes);
|
|
metrics.recordResourceAccess(matchRequest.url, true, contentType, bytes);
|
|
eventBus.emitCacheHit(matchRequest.url, bytes);
|
|
|
|
logger.log('ok', `CACHED: Found cached response for ${matchRequest.url}`);
|
|
done.resolve(cachedResponse);
|
|
return;
|
|
}
|
|
|
|
// Record cache miss
|
|
metrics.recordCacheMiss(matchRequest.url);
|
|
eventBus.emitCacheMiss(matchRequest.url);
|
|
|
|
logger.log('info', `NOTYETCACHED: Trying to cache ${matchRequest.url}`);
|
|
let newResponse: Response;
|
|
try {
|
|
// Use deduplicated fetch to prevent concurrent requests for the same resource
|
|
newResponse = await this.fetchWithDeduplication(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);
|
|
|
|
// Record resource access for per-resource tracking
|
|
const cachedContentType = newResponse.headers.get('content-type') || 'unknown';
|
|
const cachedSize = bodyBlob.size;
|
|
metrics.recordResourceAccess(matchRequest.url, false, cachedContentType, cachedSize);
|
|
|
|
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}`);
|
|
}
|
|
}
|
|
} |