/* * LibResilient Service Worker. */ // initialize the LibResilientConfig array // // this also sets some sane defaults, // which then can be modified via config.json if (typeof self.LibResilientConfig !== 'object' || self.LibResilientConfig === null) { self.LibResilientConfig = { // how long do we wait before we decide that a plugin is unresponsive, // and move on? defaultPluginTimeout: 10000, // how long should LibResilient wait before displaying the "still loading" screen // to the user if the request mode is "navigate"? // // NOTICE: the still-loading screen is only used if this setting is > 0 // NOTICE: *and* there is a stashing plugin (normally, `cache`) configured and enabled in the config // NOTICE: this is done to avoid loops -- otherwise, user would find themselves in a (manual, but still) loop stillLoadingTimeout: 5000, // plugins settings namespace // // this defines which plugins get loaded, // and the order in which they are deployed to try to retrieve content // assumption: plugin path = ./plugins/.js // // this relies on JavaScript preserving the insertion order for properties // https://stackoverflow.com/a/5525820 plugins: [{ name: 'fetch' },{ name: 'cache' } ], // which components should be logged? // this is an array of strings, components not listed here // will have their debug output disabled // // by default, the service worker and all enabled plugins // (so, all components that are used) loggedComponents: [ 'service-worker', 'fetch', 'cache' ], // should we normalize query params? // // this usually makes sense: a request to example.com/?a=a&b=b is // exactly equivalent to example.com/?b=b&a=a // // but in case a given website does something weird with query params... // ..normalization can be disabled here normalizeQueryParams: true, // do we want to use content-based MIME type detection using an external library? // // some plugins (for example, based on IPFS), receive content without a content type, // because the transport simply does not support it. so we need to find a way to // figure out the content type on our own -- that's done by the guessMimeType() function // // it can do this based on the extension of the file being requested, // but that is a limited, imperfect approach. // // it can also load an external library (currently that's `file-type`), and guess // the content type based on it. this approach is more exact, works for paths that // do not have an "extension", and works for many more MIME types than the alternative. // // however, it is also slower (content needs to be read, inspected, and compared to a lot // of signatures), and relies on an external library that needs to be distributed along // with LibResilient. // // so, since it is not needed in case of most plugins, it is disabled by default. useMimeSniffingLibrary: false } } /** * internal logging facility * * component - name of the component being logged about * if the component is not in the LibResilientConfig.loggedComponents array, * message will not be displayed * items - the rest of arguments will be passed to console.debug() */ self.log = function(component, ...items) { if ( ('LibResilientConfig' in self) && ('loggedComponents' in self.LibResilientConfig) && (self.LibResilientConfig.loggedComponents != undefined)) { if (self.LibResilientConfig.loggedComponents.indexOf(component) >= 0) { console.debug(`LibResilient [COMMIT_UNKNOWN, ${component}] ::`, ...items) } } } // Map() of file extensions to MIME types for the guessing game below // this is by no means complete, and focuses mainly on formats that // are important on the Web let ext_to_mime = new Map([ ['htm', 'text/html'], ['html', 'text/html'], ['css', 'text/css'], ['js', 'text/javascript'], ['json', 'application/json'], ['svg', 'image/svg+xml'], ['ico', 'image/x-icon'], ['gif', 'image/gif'], ['png', 'image/png'], ['jpg', 'image/jpeg'], ['jpeg', 'image/jpeg'], ['jpe', 'image/jpeg'], ['jfif', 'image/jpeg'], ['pjpeg', 'image/jpeg'], ['pjp', 'image/jpeg'], ['webp', 'image/webp'], ['avi', 'video/avi'], ['mp4', 'video/mp4'], ['mp2', 'video/mpeg'], ['mp3', 'audio/mpeg'], ['mpa', 'video/mpeg'], ['pdf', 'application/pdf'], ['txt', 'text/plain'], ['ics', 'text/calendar'], ['jsonld', 'application/ld+json'], ['mjs', 'text/javascript'], ['oga', 'audio/ogg'], ['ogv', 'video/ogg'], ['ogx', 'application/ogg'], ['opus', 'audio/opus'], ['otf', 'font/otf'], ['ts', 'video/mp2t'], ['ttf', 'font/ttf'], ['weba', 'audio/webm'], ['webm', 'video/webm'], ['webp', 'image/webp'], ['woff', 'font/woff'], ['woff2', 'font/woff2'], ['xhtml', 'application/xhtml+xml'], ['xml', 'application/xml'] ]) // preparing the variable for the MIME detection module // in case we want to use it let detectMimeFromBuffer = null /** * guess the MIME type, based on content and path extension * * important: according to RFC 7231 we should not set Content-Type if we're not sure! * https://www.rfc-editor.org/rfc/rfc7231#section-3.1.1.5 * * @param ext - the extension of the path content was fetched as * @param content - the content itself * @returns string containing the MIME type, or empty string if guessing failed */ self.guessMimeType = async function(ext, content) { // if we have file-type library loaded, that means that useMimeSniffingLibrary config field is set to true // and that we were able to load file-type.js // // in other words, we want to use it, we can use it -- so use it! if (detectMimeFromBuffer !== null) { let ft = undefined try { ft = await detectMimeFromBuffer(content) } catch (e) { self.log('service-worker', "+-- error while trying to guess MIME type based on content:", e); } // did we actually get anything? if ( (ft !== undefined) && (typeof ft === "object") && ("mime" in ft) ) { // yup! self.log('service-worker', "+-- guessed MIME type based on content: " + ft.mime); return ft.mime; } else { self.log('service-worker', "+-- unable to guess MIME type based on content.") } } // an empty string is in our case equivalent to not setting the Content-Type // as `new Blob()` with no `type` option set ends up having type set to an empty string if (ext_to_mime.has(ext)) { self.log('service-worker', "+-- guessed MIME type based on extension: " + ext_to_mime.get(ext)); return ext_to_mime.get(ext) } // if we're unable to guess the MIME type, we need to return an empty string self.log('service-worker', " +-- unable to guess the MIME type"); return ""; } /** * verifying a config data object * * we are *NOT* checking for fields that are unknown/unexpected * as resilience is more important than conrrectness here: * we do want the config to load if at all it can be loaded, * an extra field or two is not a problem here * * cdata - config data to verify */ let verifyConfigData = (cdata) => { // cdata needs to be an object if ( typeof cdata !== "object" || cdata === null ) { self.log('service-worker', 'fetched config does not contain a valid JSON object') return false; } // basic check for the plugins field if ( !("plugins" in cdata) || ! Array.isArray(cdata.plugins) ) { self.log('service-worker', 'fetched config does not contain a valid "plugins" field') return false; } // basic check for the loggedComponents if ( !("loggedComponents" in cdata) || !Array.isArray(cdata.loggedComponents) ) { self.log('service-worker', 'fetched config does not contain a valid "loggedComponents" field') return false; } // defaultPluginTimeout is optional if ("defaultPluginTimeout" in cdata) { if (!Number.isInteger(cdata.defaultPluginTimeout)) { self.log('service-worker', 'fetched config contains invalid "defaultPluginTimeout" data (integer expected)') return false; } } // stillLoadingTimeout is optional if ("stillLoadingTimeout" in cdata) { if (!Number.isInteger(cdata.stillLoadingTimeout)) { self.log('service-worker', 'fetched config contains invalid "stillLoadingTimeout" data (integer expected)') return false; } } // normalizeQueryParams is optional if ("normalizeQueryParams" in cdata) { if (cdata.normalizeQueryParams !== true && cdata.normalizeQueryParams !== false) { self.log('service-worker', 'fetched config contains invalid "normalizeQueryParams" data (boolean expected)') return false; } } // useMimeSniffingLibrary is optional if ("useMimeSniffingLibrary" in cdata) { if (cdata.useMimeSniffingLibrary !== true && cdata.useMimeSniffingLibrary !== false) { self.log('service-worker', 'fetched config contains invalid "useMimeSniffingLibrary" data (boolean expected)') return false; } } // we're good return true; } /** * cache the `config.json` response, wherever from we got it * * configURL - url of the config file * cresponse - response we're caching */ let cacheConfigJSON = async (configURL, cresponse, use_source) => { try { var cache = await caches.open(use_source) await cache.put(configURL, cresponse) self.log('service-worker', `config cached in cache: ${use_source}.`) } catch(e) { self.log('service-worker', `failed to cache config in cache ${use_source}: ${e}`) } } /** * get config JSON and verify it's valid * * cresponse - the Response object to work with */ let getConfigJSON = async (cresponse) => { if ( (cresponse === undefined) || (cresponse === null) || (typeof cresponse !== 'object') || ! ("status" in cresponse) || ! ("statusText" in cresponse) ) { self.log('service-worker', 'config.json response is undefined or invalid') return false; } if (cresponse.status != 200) { self.log('service-worker', `config.json response status is not 200: ${cresponse.status} ${cresponse.statusText})`) return false; } // cloning the response before applying json() // so that we can cache the response later var cdata = await cresponse.clone().json() if (verifyConfigData(cdata)) { return cdata; } return false; } /** * execute on the configuration * * load plugin modules, making constructors available * cycle through the plugin config instantiating plugins and their dependencies */ let executeConfig = (config) => { // working on a copy of the plugins config so that config.plugins remains unmodified // in case we need it later (for example, when re-loading the config) let pluginsConfig = [...config.plugins] // this is the stash for plugins that need dependencies instantiated first let dependentPlugins = new Array() // do we have any stashing plugins enabled? // this is important for the still-loading screen let stashingEnabled = false // only now load the plugins (config.json could have changed the defaults) while (pluginsConfig.length > 0) { // get the first plugin config from the array let pluginConfig = pluginsConfig.shift() self.log('service-worker', `handling plugin type: ${pluginConfig.name}`) // load the relevant plugin script (if not yet loaded) if (!LibResilientPluginConstructors.has(pluginConfig.name)) { self.log('service-worker', `${pluginConfig.name}: loading plugin's source`) self.importScripts(`./plugins/${pluginConfig.name}/index.js`) } // do we have any dependencies we should handle first? if (typeof pluginConfig.uses !== "undefined") { self.log('service-worker', `${pluginConfig.name}: ${pluginConfig.uses.length} dependencies found`) // move the dependency plugin configs to LibResilientConfig to be worked on next for (let i=(pluginConfig.uses.length); i--; i>=0) { self.log('service-worker', `${pluginConfig.name}: dependency found: ${pluginConfig.uses[i].name}`) // put the plugin config in front of the plugin configs array pluginsConfig.unshift(pluginConfig.uses[i]) // set each dependency plugin config to false so that we can keep track // as we fill those gaps later with instantiated dependency plugins pluginConfig.uses[i] = false } // stash the plugin config until we have all the dependencies handled self.log('service-worker', `${pluginConfig.name}: not instantiating until dependencies are ready`) dependentPlugins.push(pluginConfig) // move on to the next plugin config, which at this point will be // the first of dependencies for the plugin whose config got stashed continue; } do { // if the plugin is not enabled, no instantiation for it nor for its dependencies // if the pluginConfig does not have an "enabled" field, it should be assumed to be "true" if ( ( "enabled" in pluginConfig ) && ( pluginConfig.enabled != true ) ) { self.log('service-worker', `skipping ${pluginConfig.name} instantiation: plugin not enabled (dependencies will also not be instantiated)`) pluginConfig = dependentPlugins.pop() if (pluginConfig !== undefined) { let didx = pluginConfig.uses.indexOf(false) pluginConfig.uses.splice(didx, 1) } continue; } // instantiate the plugin let plugin = LibResilientPluginConstructors.get(pluginConfig.name)(self, pluginConfig) self.log('service-worker', `${pluginConfig.name}: instantiated`) // is this a stashing plugin? // we need at least one stashing plugin to be able to use the still-loading screen stashingEnabled = stashingEnabled || ( ( "stash" in plugin ) && ( typeof plugin.stash === "function" ) ) // do we have a stashed plugin that requires dependencies? if (dependentPlugins.length === 0) { // no we don't; so, this plugin goes directly to the plugin list self.LibResilientPlugins.push(plugin) // we're done here self.log('service-worker', `${pluginConfig.name}: no dependent plugins left, pushing directly to LibResilientPlugins`) break; } // at this point clearly there is at least one element in dependentPlugins // so we can safely assume that the freshly instantiated plugin is a dependency // // in that case let's find the first empty spot for a dependency let didx = dependentPlugins[dependentPlugins.length - 1].uses.indexOf(false) // assign the freshly instantiated plugin as that dependency dependentPlugins[dependentPlugins.length - 1].uses[didx] = plugin self.log('service-worker', `${pluginConfig.name}: assigning as dependency (#${didx}) to ${dependentPlugins[dependentPlugins.length - 1].name}`) // was this the last one? if (didx >= dependentPlugins[dependentPlugins.length - 1].uses.length - 1) { // yup, last one! self.log('service-worker', `${pluginConfig.name}: this was the last dependency of ${dependentPlugins[dependentPlugins.length - 1].name}`) // we can now proceed to instantiate the last element of dependentPlugins pluginConfig = dependentPlugins.pop() continue } // it is not the last one, so there should be more dependency plugins to instantiate first // before we can instantiate the last of element of dependentPlugins // but that requires the full treatment, including checing the `uses` field for their configs self.log('service-worker', `${pluginConfig.name}: not yet the last dependency of ${dependentPlugins[dependentPlugins.length - 1].name}`) pluginConfig = false // if pluginConfig is not false, rinse-repeat the plugin instantiation steps // since we are dealing with the last element of dependentPlugins } while ( (pluginConfig !== false) && (pluginConfig !== undefined) ) } // do we want to use MIME type guessing based on content? // dealing with this at the very end so that we know we can safely set detectMimeFromBuffer // and not need to re-set it back in case anything fails if (config.useMimeSniffingLibrary === true) { // we do not want to hit a NetworkError and end up using the default config // much better to end up not using the fancy MIME type detection in such a case try { // we do! load the external lib self.importScripts(`./lib/file-type.js`) } catch (e) { self.log('service-worker', `error when fetching external MIME sniffing library: ${e.message}`) } if (typeof fileType !== 'undefined' && "fileTypeFromBuffer" in fileType) { detectMimeFromBuffer = fileType.fileTypeFromBuffer self.log('service-worker', 'loaded external MIME sniffing library') } else { self.log('service-worker', 'failed to load external MIME sniffing library!') } } // finally -- if we do not have *any* stashing plugins enabled, // we need to disable the still-loading screen if ( ! stashingEnabled ) { config.stillLoadingTimeout = 0 self.log('service-worker', 'still-loading screen disabled, as there are no stashing plugins enabled') } // we're good! return true; } // flag signifying if the SW has been initialized already var initDone = false // load the plugins let initServiceWorker = async () => { // if init has already been done, skip! if (initDone) { self.log('service-worker', 'skipping service-worker init, already done') return false; } // everything in a try-catch block // so that we get an informative message if there's an error try { // we'll need this later let cresponse = null let config = null // self.registration.scope contains the scope this service worker is registered for // so it makes sense to pull config from `config.json` file directly under that location // TODO: this should probably be configurable somehow let configURL = self.registration.scope + "config.json" // clean version of LibResilientPlugins // NOTICE: this assumes LibResilientPlugins is not ever used *before* this runs // NOTICE: this assumption seems to hold currently, but noting for clarity self.LibResilientPlugins = new Array() // create the LibResilientPluginConstructors map // the global... hack is here so that we can run tests; not the most elegant // TODO: find a better way self.LibResilientPluginConstructors = self.LibResilientPluginConstructors || new Map() // point backup of LibResilientPluginConstructors, in case we need to roll back // this is used during cleanup in executeConfig() // TODO: handle in a more elegant way let lrpcBackup = new Map(self.LibResilientPluginConstructors) // config sources to try let config_sources = ['v1', 'v1:verified', 'fetch'] // keep track let config_executed = false let use_source = false do { // init config data var let cdata = false // where are we getting the config.json from this time? // we eitehr get a string (name of a cache, or "fetch" for simple fetch()), // or undefined (signifying need to use the defaults) use_source = config_sources.shift() try { // are we using any kind of source, or fall back to defaults? if ( typeof use_source === 'string' ) { // some kind of source! cache? if ( ( use_source === 'v1' ) || ( use_source === 'v1:verified' ) ) { self.log('service-worker', `retrieving config.json from cache: ${use_source}.`) cresponse = await caches.match(configURL, {cacheName: use_source}) // bail early if we got nothing if (cresponse === undefined) { self.log('service-worker', `config.json not found in cache: ${use_source}.`) continue } // regular fetch? // (we don't have any plugin transports at this point, obviously...) } else if ( use_source === "fetch" ) { self.log('service-worker', `retrieving config.json using fetch().`) cresponse = await fetch(configURL) // that should not happen! } else { throw new Error(`unknown config.json source: ${use_source}; this should never happen!`) } // extract the retrieved JSON and verify it cdata = await getConfigJSON(cresponse) // do we have anything to work with? if (cdata === false) { // cached config.json was invalid; no biggie, try another cache, or fetch() if ( ( use_source === 'v1' ) || ( use_source === 'v1:verified' ) ) { self.log('service-worker', `cached config.json is not valid; cache: ${use_source}`) // if that was a fetch() config, we need to fall-back to defaults! } else { self.log('service-worker', `fetched config.json is not valid; using defaults`) } // no valid config means we need to go around again continue // we good! } else { self.log('service-worker', `valid-looking config.json retrieved.`) } // anything else just means "use defaults" } else { self.log('service-worker', `retrieving config.json failed completely, using built-in defaults.`) // defaults means an empty object here, // we're merging with actual defaults later on cdata = {} } // exception? no bueno! } catch(e) { self.log('service-worker', `exception when trying to retrieve config.json: ${e.message}`) continue } // merge configs — either with the retrieved JSON, // or with an empty object if using defaults config = {...self.LibResilientConfig, ...cdata} // try executing the config // we want to catch any and all possible errors here try { config_executed = executeConfig(config) // exception? no bueno } catch (e) { // inform self.log('service-worker', `error while executing config: ${e.message}`) // cleanup after a failed config execution self.LibResilientPluginConstructors = new Map(lrpcBackup) self.LibResilientPlugins = new Array() // we are not good config_executed = false; } // if we're using the defaults, and yet loading of the config failed // something is massively wrong if ( ( use_source === undefined ) && ( config_executed === false ) ) { // this really should never happen throw new Error('Failed to load the default config; this should never happen!') } // NOTICE: endless loop alert? // NOTICE: this is not an endless loop because cresponse can only become false if we're using the default config // NOTICE: and that single case is handled directly above } while ( ! config_executed ) // we're good self.LibResilientConfig = config self.log('service-worker', 'config loaded.') // we're good, let's cache the config as verified if we need to // that is, if it comes from the "v1" cache... if (use_source === "v1") { self.log('service-worker', `successfully loaded config.json; caching in cache: v1:verified`) await cacheConfigJSON(configURL, cresponse, 'v1:verified') // we used the v1:verified cache; we should cache config.json into the v1 cache // as that will speed things up a bit next time we need to load the service worker } else if (use_source === "v1:verified") { self.log('service-worker', `successfully loaded config.json; caching in cache: v1`) await cacheConfigJSON(configURL, cresponse, 'v1') // or, was fetch()-ed and valid (no caching if we're going with defaults, obviously) } else if (use_source === "fetch") { self.log('service-worker', `successfully loaded config.json; caching in caches: v1, v1:verified`) // we want to cache to both, so that: // 1. we get the extra bit of performance from using the v1 cache that is checked first // 2. but we get the verified config already in the v1:verified cache for later await cacheConfigJSON(configURL, await cresponse.clone(), 'v1') await cacheConfigJSON(configURL, cresponse, 'v1:verified') } // inform self.log('service-worker', `service worker initialized.\nstrategy in use: ${self.LibResilientPlugins.map(p=>p.name).join(', ')}`) initDone = true; // regardless how we got the config file, if it's older than 24h... if ( ( cresponse !== false ) && (new Date()) - Date.parse(cresponse.headers.get('date')) > 86400000) { // try to get it asynchronously through the plugins, and cache it self.log('service-worker', `config.json stale, fetching through plugins`) getResourceThroughLibResilient(configURL, {}, 'libresilient-internal', false, false) .then(async cresponse=>{ // extract JSON and verify it var cdata = await getConfigJSON(cresponse) // did that work? if (cdata === false) { // we got a false in cdata, that means it probably is invalid (or the fetch failed) self.log('service-worker', 'config.json loaded through transport other than fetch seems invalid, ignoring') return false // otherwise, we good for more in-depth testing! } else { // if we got the new config.json via a method *other* than plain old fetch(), // we will not be able to use importScripts() to load any pugins that have not been loaded already // // NOTICE: this *only* checks if we have all the necessary plugin constructors already available // which signifies that relevant code has been successfully loaded; but there are other failure modes! if (cresponse.headers.get('x-libresilient-method') != 'fetch') { // go through the plugins in the new config and check if we already have their constructors // i.e. if the plugin scripts have already been loaded let currentPlugin = cdata.plugins.shift() do { // plugin constructor not available, meaning: we'd have to importScripts() it // but we can't since this was not retrieved via fetch(), so we cannot assume // that the main domain of the website is up and available // // if we cache this newly retrieved config.json, next time the service worker gets restarted // we will end up with an error while trying to run importScripts() for this plugin // which in turn would lead to the service worker being unregistered // // if the main domain is not available, this would mean the website stops working // even though we *were* able to retrieve the new config.json via plugins! // so, ignoring this new config.json. if (!LibResilientPluginConstructors.has(currentPlugin.name)) { self.log( 'service-worker', `warning: config.json loaded through transport other than fetch, but specifies not previously loaded plugin: "${currentPlugin.name}"\nignoring the whole config.json.`) return false; } // push any dependencies into the array, at the very front // thus gradually flattening the config if ("uses" in currentPlugin) { cdata.plugins.unshift(...currentPlugin.uses) } // get the next plugin to check currentPlugin = cdata.plugins.shift() } while ( (currentPlugin !== false) && (currentPlugin !== undefined) ) } self.log('service-worker', `valid config.json successfully retrieved through plugins; caching.`) // cache it, asynchronously, in the temporary cache // as the config has not been "execute-tested" yet cacheConfigJSON(configURL, cresponse, "v1") } }) .catch((e)=>{ self.log('service-worker', `stale config.json fetch failed.`) }) } } catch(e) { // we only get a cryptic "Error while registering a service worker" // unless we explicitly print the errors out in the console console.error(e) // we got an error while initializing the service worker! // better play it safe! self.registration.unregister() return false } return true; } /** * fetch counter per clientId * * we need to keep track of active fetches per clientId * so that we can inform a given clientId when we're completely done */ self.activeFetches = new Map(); /** * decrement fetches counter * and inform the correct clientId if all is finished done */ let decrementActiveFetches = (clientId) => { // decrement the fetch counter for the client self.activeFetches.set(clientId, self.activeFetches.get(clientId)-1) self.log('service-worker', '+-- activeFetches[' + clientId + ']:', self.activeFetches.get(clientId)) if (self.activeFetches.get(clientId) === 0) { self.log('service-worker', 'All fetches done!') // inform the client // client has to be smart enough to know if that is just temporary // (and new fetches will fire in a moment, because a CSS file just // got fetched) or not postMessage(clientId, { allFetched: true }).then(()=>{ self.log('service-worker', 'all-fetched message queued.') }) } } /* * returns a Promise that either resolves or rejects after a set timeout * optionally with a specific error message * * time - the timeout (in ms) * timeout_resolves - whether the Promise should resolve() or reject() when hitting the timeout (default: false (reject)) * error_message - optional error message to use when rejecting (default: false (no error message)) * * returns an array containing: * - timeout-related Promise as element 0 * - timeoutID as element 1 */ let promiseTimeout = (time, timeout_resolves=false, error_message=false) => { let timeout_id = null let timeout_promise = new Promise((resolve, reject)=>{ timeout_id = setTimeout(()=>{ if (timeout_resolves) { resolve(time); } else { if (error_message) { reject(new Error(error_message)) } else { reject(time) } } }, time); }); // we need both the promise and the timeout ID // so that we can clearTimeout() if/when needed return [timeout_promise, timeout_id] }; /* ========================================================================= *\ |* === LibResilientClient === *| \* ========================================================================= */ /** * Libresilient client class * * handles communication with a client * * TODO: track active fetches as part of this class? * TODO: https://gitlab.com/rysiekpl/libresilient/-/issues/83 */ let LibResilientClient = class { async postMessage(message) { // log self.log('service-worker', 'postMessage():', JSON.stringify(message)) // add our message to the message queue this.messageQueue.push(message) // try to get the client from Client API based on clientId if (! this.client) { this.client = await self .clients .get(this.clientId) } // now, we might still not have a valid client here if (! this.client) { // store it for later, when we do get a valid client self.log('service-worker', `postMessage(): no valid client for id: ${this.clientId}, added message to the queue`) // we have a valid client, it seems! } else { // we want all messages to be delivered, and in order they were added // our message is at the end and will get handled in due course let msg = false while (msg = this.messageQueue.shift()) { try { this.client.postMessage(msg); } catch (err) { // if we fail for whatever reason, bail from the loop self.log('service-worker', `postMessage(): client seems valid, but postMessage failed; message left in the queue\n- Error message: ${err}`) this.messageQueue.unshift(msg) break } } } } constructor(clientId) { self.log('service-worker', `new client: ${clientId}`) // we often get the clientId long before // we are able to get a valid client out of it // // so we need to keep both this.clientId = clientId this.client = null; // queued messages for when we have a client available this.messageQueue = [] } } // map of all known clients let LibResilientClients = new Map() /** * getting a client based on clientId and sending a message * (or queueing it for later if we cannot get a valid client) */ let postMessage = async (clientId, message) => { // do we already have a LibResilientClient instance for that client id? let client = LibResilientClients.get(clientId) // if not, create it if (client === undefined) { client = new LibResilientClient(clientId) LibResilientClients.set(clientId, client) } // send (or queue) the message await client.postMessage(message) } /* ========================================================================= *\ |* === LibResilientResourceInfo === *| \* ========================================================================= */ /** * LibResilient resource info class * * keeps the values as long as the service worker is running, * and communicates all changes to relevant clients * * clients are responsible for saving and keeping the values across * service worker restarts, if that's required */ let LibResilientResourceInfo = class { /** * constructor * needed to set the URL and clientId */ constructor(url, clientId) { // actual values of the fields // only used internally, and stored into the Indexed DB this.values = { url: '', // read only after initialization clientId: null, // the client on whose behalf that request is being processed method: null, // name of the current plugin (in case of state:running) or last plugin (for state:failed or state:success) state: null, // can be "failed", "success", "running" serviceWorker: 'COMMIT_UNKNOWN' // this will be replaced by commit sha in CI/CD; read-only } // errors from the plugins, contains tuples: [plugin-name, exception-or-response-object] this.errors = [] // queued messages for when we have a client available this.messageQueue = [] // set it this.values.url = url this.values.clientId = clientId // we might not have a non-empty clientId if it's a cross-origin fetch if (clientId) { postMessage( clientId, {...this.values} ) } } /** * update this.values and immediately postMessage() to the relevant client * * data - an object with items to set in this.values */ update(data) { // debug var msg = 'Updated LibResilientResourceInfo for: ' + this.values.url // was there a change? if not, no need to postMessage var changed = false // update simple read-write properties Object .keys(data) .filter((k)=>{ return ['method', 'state'].includes(k) }) .forEach((k)=>{ msg += '\n+-- ' + k + ': ' + data[k] if (this.values[k] !== data[k]) { msg += ' (changed!)' changed = true } this.values[k] = data[k] }) // start preparing the data to postMessage() over to the client let msgdata = {...this.values} // handle any error related info if ('error' in data) { // push the error info, along with method that generated it, onto the error stack this.errors.push([this.values.method, data.error]) // response? if ( (typeof data.error === 'object') && ("statusText" in data.error) ) { msgdata.error = `HTTP status: ${data.error.status} ${data.error.statusText}` // nope, exception } else { msgdata.error = data.error.toString() } changed = true } self.log('service-worker', msg) // send the message to the client if (changed) { postMessage( this.values.clientId, msgdata ); } } /** * method property */ get method() { return this.values.method } /** * state property */ get state() { return this.values.state } /** * serviceWorker property (read-only) */ get serviceWorker() { return this.values.serviceWorker } /** * url property (read-only) */ get url() { return this.values.url } /** * clientId property (read-only) */ get clientId() { return this.values.clientId } } /* ========================================================================= *\ |* === Main Brain of LibResilient === *| \* ========================================================================= */ /** * generate Request()-compatible init object from an existing Request * * req - the request to work off of */ let initFromRequest = (req) => { return { method: req.method, // TODO: ref. https://gitlab.com/rysiekpl/libresilient/-/issues/23 //headers: req.headers, TODO: commented out: https://stackoverflow.com/questions/32500073/request-header-field-access-control-allow-headers-is-not-allowed-by-itself-in-pr //mode: req.mode, TODO: commented out because mode: navigate is haram in service worker, it seems //credentials: req.credentials, TODO: commented out because credentials: "include" is haram if the Access-Control-Allow-Origin header is '*' cache: req.cache, redirect: req.redirect, referrer: req.referrer, integrity: req.integrity } } /** * run a plugin's fetch() method * while handling all the auxiliary stuff like saving info in reqInfo * * plugin - the plugin to use * url - string containing the URL to fetch * init - Request() initialization parameters * reqInfo - instance of LibResilientResourceInfo */ let libresilientFetch = (plugin, url, init, reqInfo) => { // we really need to catch any exceptions here // otherwise other plugins will not run! // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch#gotchas_when_throwing_errors try { // do we actually have a plugin to work with? if ( (! plugin) || (typeof plugin !== "object") || ! ("name" in plugin) || ! ("fetch" in plugin) ) { return Promise.reject( new Error("Plugin is not valid.") ) } // status of the plugin reqInfo.update({ method: (plugin && "name" in plugin) ? plugin.name : "unknown", state: "running" }) // log stuff self.log('service-worker', "LibResilient Service Worker handling URL:", url, '\n+-- init:', Object.getOwnPropertyNames(init).map(p=>`\n - ${p}: ${init[p]}`).join(''), '\n+-- using method(s):', plugin.name ) // starting the fetch... // if it errors out immediately, at least we don't have to deal // with a dangling promise timeout, set up below let fetch_promise = plugin .fetch(url, init) .then((response)=>{ // 5xx? that's a paddlin' // we do want to pass 3xx and 4xx on back to the client though! if (response.status >= 500) { // throw an Error to fall back to LibResilient: throw new Error('HTTP Error: ' + response.status + ' ' + response.statusText); } // ok, we're good return response }) let timeout_promise, timeout_id [timeout_promise, timeout_id] = promiseTimeout( self.LibResilientConfig.defaultPluginTimeout, false, `LibResilient request using ${plugin.name} timed out after ${self.LibResilientConfig.defaultPluginTimeout}ms.` ) // making sure there are no dangling promises etc // // this has to happen asynchronously fetch_promise // make sure the timeout is cancelled as soon as the promise resolves // we do not want any dangling promises/timeouts after all! .finally(()=>{ clearTimeout(timeout_id) }) // no-op to make sure we don't end up with dangling rejected premises .catch((e)=>{}) // race the plugin(s) vs. the timeout return Promise .race([ fetch_promise, timeout_promise ]); } catch(e) { return Promise.reject(e) } } /** * calling a libresilient plugin function on the first plugin that implements it * * call - method name to call * args - arguments that will be passed to it */ let callOnLibResilientPlugin = (call, args) => { // find the first plugin implementing the method for (let i=0; i { // set-up reqInfo for the fetch event var reqInfo = new LibResilientResourceInfo(url, clientId) // fetch counter self.activeFetches.set(clientId, self.activeFetches.get(clientId)+1) // filter out stash plugins if need be var LibResilientPluginsRun = self.LibResilientPlugins.filter((plugin)=>{ return ( useStashed || typeof plugin.stash !== 'function') }) /** * this uses Array.reduce() to chain the LibResilientPlugins[]-generated Promises * using the Promise the first registered plugin as the default value * * see: https://css-tricks.com/why-using-reduce-to-sequentially-resolve-promises-works/ * * this also means that LibResilientPlugins[0].fetch() below will run first * (counter-intutively!) * * we are slice()-ing it so that the first plugin is only run once; it is * run in the initialValue parameter below already * * ref: * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/Reduce */ return LibResilientPluginsRun .slice(1) .reduce( (prevPromise, currentPlugin)=>{ return prevPromise.catch((error)=>{ self.log('service-worker', "LibResilient plugin error for:", url, '\n+-- method : ' + reqInfo.method, '\n+-- error : ' + error.toString()) // save info in reqInfo -- status of the previous method reqInfo.update({ error: error }) return libresilientFetch(currentPlugin, url, init, reqInfo) }) }, // this libresilientFetch() will run first // all other promises generated by LibResilientPlugins[] will be chained on it // using the catch() in reduce() above // skipping this very first plugin by way of slice(1) libresilientFetch(LibResilientPluginsRun[0], url, init, reqInfo) ) .then((response)=>{ // we got a successful response decrementActiveFetches(clientId) // record the success reqInfo.update({ state:"success" }) // get the plugin that was used to fetch content let plugin = self.LibResilientPlugins.find(p=>p.name===reqInfo.method) // if it's a stashing plugin... if (typeof plugin.stash === 'function') { // we obviously do not want to stash self.log('service-worker', 'not stashing, since resource is already retrieved by a stashing plugin:', url); // since we got the data from a stashing plugin, // let's run the rest of plugins in the background to check if we can get a fresher resource // and stash it in cache for later use self.log('service-worker', 'starting background no-stashed fetch for:', url); // event.waitUntil? // https://stackoverflow.com/questions/37902441/what-does-event-waituntil-do-in-service-worker-and-why-is-it-needed/37906330#37906330 try { getResourceThroughLibResilient(url, init, clientId, false, true, response.clone()) .catch((e)=>{ self.log('service-worker', 'background no-stashed fetch failed for:', url, `\n+-- error: ${e}`); }) } catch(e) { self.log('service-worker', 'background no-stashed fetch failed for:', url, `\n+-- error: ${e}`); } // return the response so that stuff can keep happening return response // otherwise, let's see if we want to stash // and if we already had a stashed version that differs } else { // do we have a stashed version that differs? if (stashedResponse && stashedResponse.headers) { // this is where we check if the response from whatever plugin we got it from // is newer than what we've stashed self.log('service-worker', 'checking freshness of stashed version of:', url, '\n+-- stashed from :', stashedResponse.headers.get('X-LibResilient-Method'), '\n+-- fetched using :', response.headers.get('X-LibResilient-Method'), '\n+-- stashed X-LibResilient-ETag :', stashedResponse.headers.get('X-LibResilient-ETag'), '\n+-- fetched X-LibResilient-ETag :', response.headers.get('X-LibResilient-ETag')) // if the method does not match, or if it matches but the ETag doesn't // we have a different response // which means *probably* fresher content if ( ( stashedResponse.headers.get('X-LibResilient-Method') !== response.headers.get('X-LibResilient-Method') ) || ( stashedResponse.headers.get('X-LibResilient-ETag') !== response.headers.get('X-LibResilient-ETag') ) ) { // inform! self.log('service-worker', 'fetched version method or ETag differs from stashed for:', url) postMessage(reqInfo.clientId, { url: url, fetchedDiffers: true }) // TODO: this should probably modify doStash? } } // do we want to stash? if (doStash) { // find the first stashing plugin for (let i=0; i{ hdrs += `\n +-- ${k} : ${v}` }) self.log( 'service-worker', `stashing a successful fetch of: ${url}`, `\n+-- fetched using : ${response.headers.get('X-LibResilient-Method')}`, `\n+-- stashing using : ${self.LibResilientPlugins[i].name}`, hdrs ) // working on clone()'ed response so that the original one is not touched // TODO: should a failed stashing break the flow here? probably not! return self.LibResilientPlugins[i].stash(response.clone(), url) .then((res)=>{ // original response will be needed further down return response }) } } } } // if we're here it means we went through the whole list of plugins // and found not a single stashing plugin // or we don't want to stash the resources in the first place // that's fine, but let's make sure the response goes forth return response }) // a final catch... in case all plugins fail .catch((err)=>{ self.log('service-worker', `all plugins failed for:\n+-- url : ${url}`) // cleanup reqInfo.update({ state: "failed", error: err }) decrementActiveFetches(clientId) // print out all the errors from plugins in console for debugging purposes self.log('service-worker', `request errored out:\n+-- url: ${reqInfo.url}\n+-- plugin errors:\n${ reqInfo .errors .reduce((acc, cur)=>{ if ( (typeof cur[0] !== "string") || (cur[0].length === 0) ) { cur[0] = '' } return acc + ' ' + cur.join(': ') + '\n' }, '')}`) // return the error wrapped in a rejected promise return Promise.reject(err) }) } /* ========================================================================= *\ |* === Setting up the event handlers === *| \* ========================================================================= */ self.addEventListener('install', async (event) => { let init_promise = initServiceWorker() await event.waitUntil(init_promise) if (await init_promise === true) { self.skipWaiting() // "COMMIT_UNKNOWN" will be replaced with commit ID self.log('service-worker', "installed LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); } }); self.addEventListener('activate', async event => { await event.waitUntil(self.clients.claim()) self.log('service-worker', "activated LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); }); /* * messages to be used on the still-loading screen and on the error page * * TODO: make this configurable via config.json * TODO: https://gitlab.com/rysiekpl/libresilient/-/issues/82 */ let still_loading_messages = { title: "Still loading", body: "The resource is still being loaded, thank you for your patience.

This page will auto-reload in a few seconds. If it does not, please click here." } let success_messages = { title: "Loaded, redirecting!", body: "The resource has loaded, you are being redirected." } let failure_messages = { title: "Loading failed.", body: "We're sorry, we were unable to load this resource." } /** * this function returns the still-loading screen and error page HTML * * @param init_msgs text to initialize the HTML with * @param success_msgs text to use upon request success; false disables them (false by default) * @param failure_msgs text to use upon failure; false disables them (false by default) */ let getUserFacingHTML = (init_msgs, success_msgs=false, failure_msgs=false) => { // header and main part of the HTML page let html = `${init_msgs.title}

${init_msgs.title}` if (success_msgs !== false) { html += `` } html += `

attempts: 1

${init_msgs.body}

` // return what we got out of that return html } self.addEventListener('fetch', async event => { return void event.respondWith(async function () { // initialize the SW; this is necessary as SW can be stopped at any time // and restarted when an event gets triggered -- `fetch` is just such an event. // // `install` and `activate` events only handle the initial installation of the SW // this means that we might end up here without initServiceWorker() ever being run // and so in a situation where plugins have never been actually set-up! // // the good news is that the config.json should have been cached already await initServiceWorker() // if event.resultingClientId is available, we need to use this // otherwise event.clientId is what we want // ref. https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/resultingClientId var clientId = (event.clientId !== null) ? event.clientId : 'unknown-client' if (event.resultingClientId) { clientId = event.resultingClientId // yeah, we seem to have to send the client their clientId // because there is no way to get that client-side // and we need that for sane messaging later // // so let's also send the plugin list, why not // // *sigh* JS is great *sigh* postMessage(clientId, { clientId: clientId, plugins: self.LibResilientPlugins.map((p)=>{return p.name}), serviceWorker: 'COMMIT_UNKNOWN' }) } // counter! if (typeof self.activeFetches.get(clientId) !== "number") { self.activeFetches.set(clientId, 0) } // info self.log('service-worker', "Fetching!", "\n+-- url :", event.request.url, "\n+-- mode :", event.request.mode, "\n+-- clientId :", event.clientId, "\n+-- resultingClientId:", event.resultingClientId, "\n +-- activeFetches[" + clientId + "]:", self.activeFetches.get(clientId) ) // External requests go through a regular fetch() if (!event.request.url.startsWith(self.location.origin)) { self.log('service-worker', 'External request, using standard fetch(); current origin: ' + self.location.origin) return fetch(event.request); } // Non-GET requests go through a regular fetch() if (event.request.method !== 'GET') { self.log('service-worker', 'Non-GET request, using standard fetch()') return fetch(event.request); } // clean the URL, removing any fragment identifier var url = event.request.url.replace(/#.+$/, ''); // normalize query params, if we want that if (self.LibResilientConfig.normalizeQueryParams) { self.log('service-worker', 'normalizing query params') url = url.split('?') if (url.length > 1) { url[1] = url[1].split('&').sort().join('&') } url = url.join('?') } // get the init object from Request var init = initFromRequest(event.request) // GET requests to our own domain that are *not* #libresilient-info requests // get handled by plugins in case of an error let lrPromise = getResourceThroughLibResilient(url, init, clientId) // are we navigating, or just fetching some resource? if ( event.request.mode === 'navigate' ) { // this is the promise we will want to return in the end let finalPromise = null // navigating! is the still-loading screen enabled? if ( self.LibResilientConfig.stillLoadingTimeout > 0 ) { // it is enabled! self.log('service-worker', `handling a navigate request; still-loading timeout: ${self.LibResilientConfig.stillLoadingTimeout}.`) let slPromise, slTimeoutId [slPromise, slTimeoutId] = promiseTimeout(self.LibResilientConfig.stillLoadingTimeout, true) // make sure to clear the timeout related to slPromise // in case we manage to get the content through the plugins lrPromise .then(()=>{ self.log('service-worker', `content retrieved; still-loading timeout cleared.`) clearTimeout(slTimeoutId) }) // prepare a Promise that races the "still loading" screen promise against the LibResilient plugins finalPromise = Promise.race([ // regular fetch-through-plugins lrPromise, // the "still loading screen" // // this will delay a specified time, and ten return a Response // with very basic HTML informing the user that the page is still loading, // a Refresh header set, and a link for the user to reload the screen manually slPromise .then(()=>{ // inform self.log('service-worker', 'handling a navigate request is taking too long, showing the still-loading screen') // we need to create a new Response object // with all the headers added explicitly, // since response.headers is immutable var responseInit = { status: 202, statusText: "Accepted", headers: {}, url: url }; responseInit.headers['Content-Type'] = "text/html" responseInit.headers['X-LibResilient-Method'] = "still-loading" // get the still-loading page contents let stillLoadingHTML = getUserFacingHTML( still_loading_messages, success_messages, failure_messages ) let blob = new Blob( [stillLoadingHTML], {type: "text/html"} ) return new Response( blob, responseInit ) }) ]) // okay, no still-loading screen, but this is still a navigate request, so we want to display something to the user } else { self.log('service-worker', `handling a navigate request, but still-loading screen is disabled.`) finalPromise = lrPromise } // return finalPromise, with a catch! return finalPromise.catch((e)=>{ // inform self.log('service-worker', 'handling a failed navigate request, showing the user-facing error screen') // we need to create a new Response object // with all the headers added explicitly, // since response.headers is immutable var responseInit = { status: 404, statusText: "Not Found", headers: {}, url: url }; responseInit.headers['Content-Type'] = "text/html" responseInit.headers['X-LibResilient-Method'] = "failed" // get the still-loading page contents // it only needs to display the failure messages let stillLoadingHTML = getUserFacingHTML( failure_messages ) let blob = new Blob( [stillLoadingHTML], {type: "text/html"} ) return new Response( blob, responseInit ) }) // nope, just fetching a resource } else { // no need for a still-loading screen, no need for an user-facing error screen if the request fails self.log('service-worker', 'handling a regular request; still-loading screen will not be used.') return lrPromise } }()) }); /** * assumptions to be considered: * every message contains clientId (so that we know where to respond if/when we need to) */ self.addEventListener('message', async (event) => { // initialize the SW; this is necessary as SW can be stopped at any time // and restarted when an event gets triggered -- `message` is just such an event. // // `install` and `activate` events only handle the initial installation of the SW // this means that we might end up here without initServiceWorker() ever being run // and so in a situation where plugins have never been actually set-up! // // the good news is that the config.json should have been cached already await initServiceWorker() // inform var msg = 'Message received!' Object.keys(event.data).forEach((k)=>{ msg += '\n+-- key: ' + k + " :: val: " + event.data[k] }) self.log('service-worker', msg); /* * supporting stash(), unstash(), and publish() only */ if (event.data.stash || event.data.unstash || event.data.publish) { if (event.data.stash) { callOnLibResilientPlugin('stash', event.data.stash) } if (event.data.unstash) { callOnLibResilientPlugin('unstash', event.data.unstash) } if (event.data.publish) { callOnLibResilientPlugin('publish', event.data.publish) } } });