Skip to content

Instantly share code, notes, and snippets.

@dsheiko
Last active October 27, 2023 19:59
  • Star 8 You must be signed in to star a gist
  • Fork 3 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save dsheiko/8a5878678371f950d37f3ee074fe8031 to your computer and use it in GitHub Desktop.
Service-worker to prefetch remote images (with expiration) and respond with fallback one when image cannot be fetched
<!DOCTYPE html>
<html>
<head>
<title>Service-worker demo</title>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<script>
if ( "serviceWorker" in navigator ) {
navigator.serviceWorker
.register( "./service-worker.js", { scope: './' })
.then(function() {
console.log( "Service Worker Registered" );
})
.catch(function( err ) {
console.log( "Service Worker Failed to Register", err );
});
}
</script>
</head>
<style>
img {
display: block;
margin: 8px;
}
</style>
<body>
<img src="http://ipsumimage.appspot.com/216x120,ff7700?l=foo.png" alt="Image" itemprop="image">
<img src="http://ipsumimage.appspot.com/216x120,ff7700?l=bar.png" alt="Image" itemprop="image">
<img src="http://ipsumimage.appspot.com/216x120,ff7700?l=baz.png" alt="Image" itemprop="image">
<img src="http://ipsumimage.appspot.com/216x120,ff7700?l=quiz.png" alt="Image" itemprop="image">
</body>
</html>
/**
* Service worker interepts requests for images
* It puts retrieved images in cache for 10 minutes
* If image not found responds with fallback
*/
var INVALIDATION_INTERVAL = 10 * 60 * 1000; // 10 min
var NS = "MAGE";
var SEPARATOR = "|";
var VERSION = Math.ceil( now() / INVALIDATION_INTERVAL );
/**
* Helper to get current timestamp
* @returns {Number}
*/
function now() {
var d = new Date();
return d.getTime();
}
/**
* Build cache storage key that includes namespace, url and record version
* @param {String} url
* @returns {String}
*/
function buildKey( url ) {
return NS + SEPARATOR + url + SEPARATOR + VERSION;
}
/**
* The complete Triforce, or one or more components of the Triforce.
* @typedef {Object} RecordKey
* @property {String} ns - namespace
* @property {String} url - request identifier
* @property {String} ver - record varsion
*/
/**
* Parse cache key
* @param {String} key
* @returns {RecordKey}
*/
function parseKey( key ) {
var parts = key.split( SEPARATOR );
return {
ns: parts[ 0 ],
key: parts[ 1 ],
ver: parseInt( parts[ 2 ], 10 )
};
}
/**
* Invalidate records matchinf actual version
*
* @param {Cache} caches
* @returns {Promise}
*/
function purgeExpiredRecords( caches ) {
console.log( "Purging..." );
return caches.keys().then(function( keys ) {
return Promise.all(
keys.map(function( key ) {
var record = parseKey( key );
if ( record.ns === NS && record.ver !== VERSION ) {
console.log("deleting", key);
return caches.delete( key );
}
})
);
});
}
/**
* Proxy request using cache-first strategy
*
* @param {Cache} caches
* @param {Request} request
* @returns {Promise}
*/
function proxyRequest( caches, request ) {
var key = buildKey( request.url );
// set namespace
return caches.open( key ).then( function( cache ) {
// check cache
return cache.match( request ).then( function( cachedResponse ) {
if ( cachedResponse ) {
console.info( "Take it from cache", request.url );
return cachedResponse;
}
// { mode: "no-cors" } gives opaque response
// https://fetch.spec.whatwg.org/#concept-filtered-response-opaque
// so we cannot get info about response status
return fetch( request.clone() )
.then(function( networkResponse ) {
if ( networkResponse.type !== "opaque" && networkResponse.ok === false ) {
throw new Error( "Resource not available" );
}
console.info( "Fetch it through Network", request.url, networkResponse.type );
cache.put( request, networkResponse.clone() );
return networkResponse;
}).catch(function() {
console.error( "Failed to fetch", request.url );
// Placeholder image for the fallback
return fetch( "./placeholder.jpg", { mode: "no-cors" });
});
});
});
}
self.addEventListener( "install", function( event ) {
event.waitUntil( self.skipWaiting() );
});
self.addEventListener( "activate", function( event ) {
event.waitUntil( purgeExpiredRecords( caches ) );
});
self.addEventListener( "fetch", function( event ) {
var request = event.request;
console.log( "Detected request", request.url );
if ( request.method !== "GET" ||
!request.url.match( /\.(jpe?g|png|gif|svg)$/ ) ) {
return;
}
console.log( "Accepted request", request.url );
event.respondWith(
proxyRequest( caches, request )
);
});
@nichoth
Copy link

nichoth commented Aug 8, 2021

Where is the variable caches created?

@dsheiko
Copy link
Author

dsheiko commented Aug 9, 2021

@nichoth
Copy link

nichoth commented Aug 9, 2021

Thanks

@pferreirafabricio
Copy link

Simple and effective, thanks @dsheiko!

@itsjavi
Copy link

itsjavi commented Oct 27, 2023

Thanks for the solution, but this creates a different cache store per asset (I ended up with hundreds of MAGE|* cache stores). I created a version (specially for NextJS or React apps) that uses a single cache store and every put just adds a new entry to it like in this picture:

image

The adapted code: https://gist.github.com/itsjavi/f3913770eb34d6d752e780c46e80cdea

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment