fix(Service Worker): Refactor and clean up the cache logic in the Service Worker to improve maintainability and handle Safari-specific cache behavior.
This commit is contained in:
parent
6cedd53d61
commit
270230b0ca
@ -1,5 +1,13 @@
|
||||
# Changelog
|
||||
|
||||
## 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.
|
||||
|
||||
|
@ -3,6 +3,6 @@
|
||||
*/
|
||||
export const commitinfo = {
|
||||
name: '@api.global/typedserver',
|
||||
version: '3.0.61',
|
||||
version: '3.0.62',
|
||||
description: 'A TypeScript-based project for easy serving of static files with support for live reloading, compression, and typed requests.'
|
||||
}
|
||||
|
@ -11,14 +11,14 @@ export class CacheManager {
|
||||
};
|
||||
|
||||
/**
|
||||
* Hard cached domains are always attempted to be cached, regardless of browser.
|
||||
* Hard cached domains are always attempted to be cached.
|
||||
* 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 Safari’s handling of cached CORS responses is problematic.
|
||||
* Soft cached domains will be cached normally on non‑Safari browsers,
|
||||
* but on Safari caching is bypassed to avoid CORS issues.
|
||||
*/
|
||||
public softCachedDomains: string[];
|
||||
|
||||
@ -42,87 +42,90 @@ export class CacheManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given URL is in one of the hard cached domains.
|
||||
* Returns true if the given URL matches a hard cached domain.
|
||||
*/
|
||||
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.
|
||||
* Returns true if the given URL matches a soft cached domain.
|
||||
*/
|
||||
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).
|
||||
* Returns true if the given URL should be cached (hard or soft).
|
||||
*/
|
||||
private isCacheable(url: string): boolean {
|
||||
return this.isHardCached(url) || this.isSoftCached(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Request with appropriate CORS settings.
|
||||
*/
|
||||
private createMatchRequest(requestArg: Request): Request {
|
||||
let matchRequest: Request;
|
||||
// For internal requests, use the original request.
|
||||
if (requestArg.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin)) {
|
||||
matchRequest = requestArg;
|
||||
} else {
|
||||
// For external requests, choose the request mode based on whether the URL is soft-cached.
|
||||
const isSoft = this.isSoftCached(requestArg.url);
|
||||
// For soft cached domains we use 'no-cors' to avoid CORS errors (at the expense of an opaque response).
|
||||
const mode: RequestMode = isSoft ? 'no-cors' : 'cors';
|
||||
matchRequest = new Request(requestArg.url, {
|
||||
method: requestArg.method,
|
||||
headers: requestArg.headers,
|
||||
mode,
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow'
|
||||
});
|
||||
}
|
||||
return matchRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a 500 error response.
|
||||
*/
|
||||
private async create500Response(requestArg: Request, responseArg: Response): Promise<Response> {
|
||||
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>Service worker 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>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
headers: { "Content-Type": "text/html" },
|
||||
status: 500
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up the fetch event listener and caching logic.
|
||||
*/
|
||||
private _setupCache = () => {
|
||||
const createMatchRequest = (requestArg: Request): Request => {
|
||||
let matchRequest: Request;
|
||||
// For internal requests, use the original request.
|
||||
if (requestArg.url.startsWith(this.losslessServiceWorkerRef.serviceWindowRef.location.origin)) {
|
||||
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';
|
||||
matchRequest = new Request(requestArg.url, {
|
||||
method: requestArg.method,
|
||||
headers: requestArg.headers,
|
||||
mode,
|
||||
credentials: 'same-origin',
|
||||
redirect: 'follow'
|
||||
});
|
||||
}
|
||||
return matchRequest;
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a 500 error response.
|
||||
*/
|
||||
const create500Response = async (requestArg: Request, responseArg: Response): Promise<Response> => {
|
||||
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>Service worker 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>
|
||||
</body>
|
||||
</html>
|
||||
`,
|
||||
{
|
||||
headers: { "Content-Type": "text/html" },
|
||||
status: 500
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
this.losslessServiceWorkerRef.serviceWindowRef.addEventListener('fetch', async (fetchEventArg: any) => {
|
||||
const originalRequest: Request = fetchEventArg.request;
|
||||
const parsedUrl = new URL(originalRequest.url);
|
||||
@ -143,24 +146,24 @@ export class CacheManager {
|
||||
const done = plugins.smartpromise.defer<Response>();
|
||||
fetchEventArg.respondWith(done.promise);
|
||||
|
||||
// Determine if the request should be cached.
|
||||
// Only handle GET requests for caching.
|
||||
if (originalRequest.method === 'GET' && this.isCacheable(originalRequest.url)) {
|
||||
// Check if running on Safari.
|
||||
// Determine if the browser is Safari.
|
||||
const userAgent = (self.navigator && self.navigator.userAgent) || "";
|
||||
const isSafari = /Safari/.test(userAgent) && !/Chrome/.test(userAgent);
|
||||
|
||||
// For soft cached domains on Safari, bypass caching.
|
||||
// For soft cached domains on Safari, bypass caching entirely.
|
||||
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));
|
||||
return await this.create500Response(originalRequest, new Response(err.message));
|
||||
});
|
||||
done.resolve(networkResponse);
|
||||
return;
|
||||
}
|
||||
|
||||
// For other cases, try serving from cache.
|
||||
const matchRequest = createMatchRequest(originalRequest);
|
||||
// Try to serve from cache.
|
||||
const matchRequest = this.createMatchRequest(originalRequest);
|
||||
const cachedResponse = await caches.match(matchRequest);
|
||||
if (cachedResponse) {
|
||||
logger.log('ok', `CACHED: found cached response for ${matchRequest.url}`);
|
||||
@ -170,68 +173,82 @@ export class CacheManager {
|
||||
|
||||
logger.log('info', `NOTYETCACHED: fetching and caching ${matchRequest.url}`);
|
||||
const newResponse: Response = await fetch(matchRequest).catch(async err => {
|
||||
return await create500Response(matchRequest, new Response(err.message));
|
||||
return await this.create500Response(matchRequest, new Response(err.message));
|
||||
});
|
||||
|
||||
// Do not cache responses that are not successful or are opaque (when not expected).
|
||||
if (newResponse.status > 299 || newResponse.type === 'opaque') {
|
||||
logger.log(
|
||||
'error',
|
||||
`NOTCACHED: cannot cache response for ${matchRequest.url} (status: ${newResponse.status}, type: ${newResponse.type})`
|
||||
);
|
||||
done.resolve(await create500Response(matchRequest, newResponse));
|
||||
// If the response status indicates an error, don't cache.
|
||||
if (newResponse.status > 299) {
|
||||
logger.log('error', `NOTCACHED: response for ${matchRequest.url} has status ${newResponse.status}`);
|
||||
done.resolve(await this.create500Response(matchRequest, newResponse));
|
||||
return;
|
||||
} else {
|
||||
// Open the runtime cache.
|
||||
const cache = await caches.open(this.usedCacheNames.runtimeCacheName);
|
||||
const responseToCache = newResponse.clone();
|
||||
|
||||
// Build new headers preserving all except caching-related headers.
|
||||
const headers = new Headers();
|
||||
responseToCache.headers.forEach((value, key) => {
|
||||
if (!['Cache-Control', 'cache-control', 'Expires', 'expires', 'Pragma', 'pragma'].includes(key)) {
|
||||
headers.set(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
// Ensure necessary CORS 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 caching headers to prevent browser 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`);
|
||||
done.resolve(newResponse);
|
||||
}
|
||||
|
||||
// Handle opaque responses separately.
|
||||
if (newResponse.type === 'opaque') {
|
||||
// For soft-cached domains we expect opaque responses (from no-cors fetches).
|
||||
if (this.isSoftCached(matchRequest.url)) {
|
||||
const cache = await caches.open(this.usedCacheNames.runtimeCacheName);
|
||||
// Cache the opaque response as-is.
|
||||
await cache.put(matchRequest, newResponse.clone());
|
||||
logger.log('ok', `NOWCACHED: cached opaque response for ${matchRequest.url}`);
|
||||
done.resolve(newResponse);
|
||||
return;
|
||||
} else {
|
||||
// If an opaque response comes from a non-soft domain, treat it as an error.
|
||||
logger.log('error', `NOTCACHED: opaque response received for non-soft domain ${matchRequest.url}`);
|
||||
done.resolve(await this.create500Response(matchRequest, newResponse));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// For non-opaque responses, adjust headers and cache.
|
||||
const cache = await caches.open(this.usedCacheNames.runtimeCacheName);
|
||||
const responseToCache = newResponse.clone();
|
||||
const headers = new Headers();
|
||||
responseToCache.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.
|
||||
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');
|
||||
}
|
||||
// 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 so we can create a new Response with modified headers.
|
||||
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: cached response for ${matchRequest.url}`);
|
||||
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));
|
||||
return await this.create500Response(originalRequest, new Response(err.message));
|
||||
});
|
||||
done.resolve(networkResponse);
|
||||
}
|
||||
@ -240,7 +257,7 @@ export class CacheManager {
|
||||
|
||||
/**
|
||||
* Cleans all caches.
|
||||
* Should only be run when a new service worker is activated.
|
||||
* Should 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> => {
|
||||
@ -272,4 +289,4 @@ export class CacheManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user