Compare commits

...

4 Commits

4 changed files with 88 additions and 122 deletions

View File

@ -1,5 +1,20 @@
# Changelog
## 2025-02-04 - 3.0.63 - fix(core)
Refactored caching strategy for service worker to improve compatibility and performance.
- Removed hard and soft caching distinctions.
- Simplified cache setup process.
- Improved browser caching control headers.
## 2025-02-04 - 3.0.62 - fix(Service Worker)
Refactor and clean up the cache logic in the Service Worker to improve maintainability and handle Safari-specific cache behavior.
- Refactored logic for determining cached domains, enhancing the readability and maintainability of the code.
- Improved handling of CORS settings in caching requests, notably bypassing caching for soft cached domains in Safari to avoid CORS issues.
- Enhanced error response creation for failed resource fetching, maintaining clarity on why and how certain resources were not fetched or cached.
- Revised the structure of the caching logic to ensure consistent behavior across all supported browsers.
## 2025-02-04 - 3.0.61 - fix(ServiceWorkerCacheManager)
Fixed caching mechanism to better support Safari's handling of soft-cached domains.

View File

@ -1,6 +1,6 @@
{
"name": "@api.global/typedserver",
"version": "3.0.61",
"version": "3.0.63",
"description": "A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.",
"type": "module",
"exports": {

View File

@ -3,6 +3,6 @@
*/
export const commitinfo = {
name: '@api.global/typedserver',
version: '3.0.61',
version: '3.0.63',
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
}

View File

@ -10,76 +10,24 @@ export class CacheManager {
runtimeCacheName: 'runtime'
};
/**
* Hard cached domains are always attempted to be cached, regardless of browser.
* For example, your internal origin, fonts, CDNs, etc.
*/
public hardCachedDomains: string[];
/**
* Soft cached domains will be cached normally except on Safari.
* This is useful for domains where Safaris handling of cached CORS responses is problematic.
*/
public softCachedDomains: string[];
constructor(losslessServiceWorkerRefArg: ServiceWorker) {
this.losslessServiceWorkerRef = losslessServiceWorkerRefArg;
// Default hard cached domains.
this.hardCachedDomains = [
this.losslessServiceWorkerRef.serviceWindowRef.location.origin,
'https://unpkg.com',
'https://fonts.googleapis.com',
'https://fonts.gstatic.com'
];
// Default soft cached domains.
this.softCachedDomains = [
'https://assetbroker.'
];
this._setupCache();
}
/**
* Returns true if the given URL is in one of the hard cached domains.
*/
private isHardCached(url: string): boolean {
return this.hardCachedDomains.some(domain => url.includes(domain));
}
/**
* Returns true if the given URL is in one of the soft cached domains.
*/
private isSoftCached(url: string): boolean {
return this.softCachedDomains.some(domain => url.includes(domain));
}
/**
* Returns true if the given URL is cacheable (i.e. belongs to either hard or soft domains).
*/
private isCacheable(url: string): boolean {
return this.isHardCached(url) || this.isSoftCached(url);
}
/**
* Sets up the fetch event listener and caching logic.
*/
private _setupCache = () => {
const createMatchRequest = (requestArg: Request): Request => {
// Create a matchRequest based on whether the request is internal or external.
let matchRequest: Request;
// For internal requests, use the original request.
if (requestArg.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin)) {
// Internal request; use the original.
matchRequest = requestArg;
} else {
// For external requests, create a new Request with appropriate CORS settings.
// For soft cached domains, we use 'no-cors' to keep the response opaque.
const isSoft = this.isSoftCached(requestArg.url);
const mode: RequestMode = isSoft ? 'no-cors' : 'cors';
// External request; create a new Request with appropriate CORS settings.
matchRequest = new Request(requestArg.url, {
method: requestArg.method,
headers: requestArg.headers,
mode,
mode: 'cors',
credentials: 'same-origin',
redirect: 'follow'
});
@ -107,27 +55,31 @@ export class CacheManager {
</head>
<body>
<div class="note">
<strong>Service worker running, but status 500</strong><br>
<strong>serviceworker running, but status 500</strong><br>
</div>
Service worker is unable to fetch this request.<br>
Request URL: ${requestArg.url}<br>
Response Type: ${responseArg.type}<br>
Response Body: ${await responseArg.clone().text()}<br>
serviceworker is unable to fetch this request<br>
Here is some info about the request/response pair:<br>
<br>
requestUrl: ${requestArg.url}<br>
responseType: ${responseArg.type}<br>
responseBody: ${await responseArg.clone().text()}<br>
</body>
</html>
`,
{
headers: { "Content-Type": "text/html" },
headers: {
"Content-Type": "text/html"
},
status: 500
}
);
};
// Listen for fetch events.
this.losslessServiceWorkerRef.serviceWindowRef.addEventListener('fetch', async (fetchEventArg: any) => {
// Block specific scopes.
const originalRequest: Request = fetchEventArg.request;
const parsedUrl = new URL(originalRequest.url);
// Exclude specific hosts or paths from being handled by the service worker.
if (
parsedUrl.hostname.includes('paddle.com') ||
parsedUrl.hostname.includes('paypal.com') ||
@ -135,7 +87,7 @@ export class CacheManager {
parsedUrl.pathname.startsWith('/socket.io') ||
originalRequest.url.startsWith('https://umami.')
) {
logger.log('note', `Service worker not active for ${parsedUrl.toString()}`);
logger.log('note', `serviceworker not active for ${parsedUrl.toString()}`);
return;
}
@ -143,23 +95,20 @@ export class CacheManager {
const done = plugins.smartpromise.defer<Response>();
fetchEventArg.respondWith(done.promise);
// Determine if the request should be cached.
if (originalRequest.method === 'GET' && this.isCacheable(originalRequest.url)) {
// Check if running on Safari.
const userAgent = (self.navigator && self.navigator.userAgent) || "";
const isSafari = /Safari/.test(userAgent) && !/Chrome/.test(userAgent);
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')
) {
// Check for updates asynchronously.
this.losslessServiceWorkerRef.updateManager.checkUpdate(this);
// For soft cached domains on Safari, bypass caching.
if (this.isSoftCached(originalRequest.url) && isSafari) {
logger.log('info', `Safari detected bypass caching for soft cached domain: ${originalRequest.url}`);
const networkResponse = await fetch(originalRequest).catch(async err => {
return await create500Response(originalRequest, new Response(err.message));
});
done.resolve(networkResponse);
return;
}
// For other cases, try serving from cache.
// Try to serve from cache.
const matchRequest = createMatchRequest(originalRequest);
const cachedResponse = await caches.match(matchRequest);
if (cachedResponse) {
@ -168,33 +117,41 @@ export class CacheManager {
return;
}
logger.log('info', `NOTYETCACHED: fetching and caching ${matchRequest.url}`);
// In case there is no cached response, fetch from the network.
logger.log('info', `NOTYETCACHED: trying to cache ${matchRequest.url}`);
const newResponse: Response = await fetch(matchRequest).catch(async err => {
return await create500Response(matchRequest, new Response(err.message));
});
// Do not cache responses that are not successful or are opaque (when not expected).
// If the response status is an error or the response is opaque, do not cache it.
if (newResponse.status > 299 || newResponse.type === 'opaque') {
logger.log(
'error',
`NOTCACHED: cannot cache response for ${matchRequest.url} (status: ${newResponse.status}, type: ${newResponse.type})`
`NOTCACHED: not caching response for ${matchRequest.url} due to status ${newResponse.status} and type ${newResponse.type}`
);
done.resolve(await create500Response(matchRequest, newResponse));
// Simply return the network response without caching.
done.resolve(newResponse);
return;
} else {
// Open the runtime cache.
// Cache the response.
const cache = await caches.open(this.usedCacheNames.runtimeCacheName);
const responseToCache = newResponse.clone();
// Build new headers preserving all except caching-related headers.
const responseToPutToCache = newResponse.clone();
const headers = new Headers();
responseToCache.headers.forEach((value, key) => {
if (!['Cache-Control', 'cache-control', 'Expires', 'expires', 'Pragma', 'pragma'].includes(key)) {
responseToPutToCache.headers.forEach((value, key) => {
// Preserve all headers except caching-related ones.
if (![
'Cache-Control',
'cache-control',
'Expires',
'expires',
'Pragma',
'pragma'
].includes(key)) {
headers.set(key, value);
}
});
// Ensure necessary CORS headers are present.
// Ensure CORS headers are present.
if (!headers.has('Access-Control-Allow-Origin')) {
headers.set('Access-Control-Allow-Origin', '*');
}
@ -204,46 +161,39 @@ export class CacheManager {
if (!headers.has('Access-Control-Allow-Headers')) {
headers.set('Access-Control-Allow-Headers', 'Content-Type');
}
// Set caching headers to prevent browser caching.
// Prevent browser caching while allowing service worker caching.
headers.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
headers.set('Pragma', 'no-cache');
headers.set('Expires', '0');
headers.set('Surrogate-Control', 'no-store');
// Read the response body as a blob (helps prevent issues with locked streams on Safari).
let newCachedResponse: Response;
try {
const bodyBlob = await responseToCache.blob();
newCachedResponse = new Response(bodyBlob, {
status: responseToCache.status,
statusText: responseToCache.statusText,
headers
});
} catch (err) {
newCachedResponse = newResponse;
}
await cache.put(matchRequest, newCachedResponse);
logger.log('ok', `NOWCACHED: response for ${matchRequest.url} cached successfully`);
await cache.put(matchRequest, new Response(responseToPutToCache.body, {
...responseToPutToCache,
headers
}));
logger.log('ok', `NOWCACHED: cached response for ${matchRequest.url} for subsequent requests!`);
done.resolve(newResponse);
}
} else {
// For non-cacheable requests, fetch directly from the network.
logger.log('ok', `NOTCACHED: fetching ${originalRequest.url} from origin (non-cacheable)`);
const networkResponse = await fetch(originalRequest).catch(async err => {
return await create500Response(originalRequest, new Response(err.message));
});
done.resolve(networkResponse);
// For remote requests not intended for caching, fetch directly from the origin.
logger.log(
'ok',
`NOTCACHED: not caching any responses for ${originalRequest.url}. Fetching from origin now...`
);
done.resolve(
await fetch(originalRequest).catch(async err => {
return await create500Response(originalRequest, new Response(err.message));
})
);
}
});
};
}
/**
* Cleans all caches.
* Should only be run when a new service worker is activated.
* @param reasonArg A reason for the cache cleanup.
*/
public cleanCaches = async (reasonArg = 'no reason given'): Promise<void> => {
public cleanCaches = async (reasonArg = 'no reason given') => {
logger.log('info', `MAJOR CACHEEVENT: cleaning caches now! Reason: ${reasonArg}`);
const cacheNames = await caches.keys();
@ -255,17 +205,18 @@ export class CacheManager {
return deletePromise;
});
await Promise.all(deletePromises);
};
}
/**
* Revalidates the runtime cache.
*/
public async revalidateCache(): Promise<void> {
public async revalidateCache() {
const runtimeCache = await caches.open(this.usedCacheNames.runtimeCacheName);
const cacheKeys = await runtimeCache.keys();
for (const requestArg of cacheKeys) {
// Fetch a new response for comparison.
const clonedRequest = requestArg.clone();
const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000);
const response = await plugins.smartpromise.timeoutWrap(fetch(clonedRequest), 5000); // Increased timeout for better mobile compatibility
if (response && response.status >= 200 && response.status < 300) {
await runtimeCache.delete(requestArg);
await runtimeCache.put(requestArg, response);