@ -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 n on‑ 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 cac he 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 t he 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 {
}
}
}
}
}