libresilient/service-worker.js

776 wiersze
31 KiB
JavaScript

/*
* LibResilient Service Worker.
*/
/*
* we need a Promise.any() polyfill
* so here it is
* https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/any
*
* TODO: remove once Promise.any() is implemented broadly
*/
if (typeof Promise.any === 'undefined') {
Promise.any = async (promises) => {
// Promise.all() is the polar opposite of Promise.any()
// in that it returns as soon as there is a first rejection
// but without it, it returns an array of resolved results
return Promise.all(
promises.map(p => {
return new Promise((resolve, reject) =>
// swap reject and resolve, so that we can use Promise.all()
// and get the result we need
Promise.resolve(p).then(reject, resolve)
);
})
// now, swap errors and values back
).then(
err => Promise.reject(err),
val => Promise.resolve(val)
);
};
}
// initialize the LibResilientPlugins array
if (!Array.isArray(self.LibResilientPlugins)) {
self.LibResilientPlugins = new Array()
}
// initialize the LibResilientConfig array
//
// this also sets some sane defaults,
// which then can be modified via config.js
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,
// 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/<plugin-name>.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'
]
}
}
/**
* 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 (self.LibResilientConfig.loggedComponents.indexOf(component) >= 0) {
console.debug(`LibResilient [COMMIT_UNKNOWN, ${component}] ::`, ...items)
}
}
// load the plugins
//
// everything in a try-catch block
// so that we get an informative message if there's an error
try {
// get the config
//
// self.registration.scope contains the scope this service worker is registered for
// so it makes sense to pull config from `config.js` file directly under that location
//
// TODO: providing config directly from browser-side control script via postMessage?
// TODO: `updateViaCache=imports` allows at least config.js to be updated using the cache plugin?
try {
self.importScripts(self.registration.scope + "config.js")
self.log('service-worker', 'config loaded.')
} catch (e) {
self.log('service-worker', 'config loading failed, using defaults')
}
// create the LibResilientPluginConstructors map
// the global... hack is here so that we can run tests; not the most elegant
// TODO: find a better way
var LibResilientPluginConstructors = self.LibResilientPluginConstructors || new Map()
// this is the stash for plugins that need dependencies instantiated first
var dependentPlugins = new Array()
// only now load the plugins (config.js could have changed the defaults)
while (self.LibResilientConfig.plugins.length > 0) {
// get the first plugin config from the array
let pluginConfig = self.LibResilientConfig.plugins.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}.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 (var 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
self.LibResilientConfig.plugins.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 {
// instantiate the plugin
let plugin = LibResilientPluginConstructors.get(pluginConfig.name)(self, pluginConfig)
self.log('service-worker', `${pluginConfig.name}: instantiated`)
// 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, 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)
}
// inform
self.log('service-worker', `DEBUG: Strategy in use: ${self.LibResilientPlugins.map(p=>p.name).join(', ')}`)
} 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)
throw e
}
/**
* 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
self.clients.get(clientId).then((client)=>{
if (client !== null) {
try {
client.postMessage({
allFetched: true
})
} catch(err) {
self.log("service-worker", `postMessage failed for client: ${client}\n- Error message: ${err}`)
}
}
})
.then(()=>{
self.log('service-worker', 'all-fetched message sent.')
})
}
}
/*
* 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))
*/
let promiseTimeout = (time, timeout_resolves=false, error_message=false) => {
return new Promise((resolve, reject)=>{
setTimeout(()=>{
if (timeout_resolves) {
resolve(time);
} else {
if (error_message) {
reject(new Error(error_message))
} else {
reject(time)
}
}
},time);
});
};
/* ========================================================================= *\
|* === 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,
fetchError: null,
method: null,
state: null, // can be "error", "success", "running"
serviceWorker: 'COMMIT_UNKNOWN' // this will be replaced by commit sha in CI/CD; read-only
}
this.client = null;
// 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) {
// get the client from Client API based on clientId
self.clients.get(clientId).then((client)=>{
// set the client
this.client = client
// Send a message to the client
if (this.client !== null) {
try {
this.client.postMessage(this.values);
} catch(err) {
self.log("service-worker", `postMessage failed for client: ${this.client}\n- Error message: ${err}`)
}
}
})
}
}
/**
* 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 the properties that are read-write
Object
.keys(data)
.filter((k)=>{
return ['fetchError', '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]
})
self.log('service-worker', msg)
// send the message to the client
if (this.client && changed && (this.client !== null)) {
try {
this.client.postMessage(this.values);
} catch(err) {
self.log("service-worker", `postMessage failed for client: ${this.client}\n- Error message: ${err}`)
}
}
}
/**
* fetchError property
*/
get fetchError() {
return this.values.fetchError
}
/**
* 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) => {
// status of the plugin
reqInfo.update({
method: plugin.name,
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
)
// race the plugin(s) vs. a timeout
return Promise.race([
plugin.fetch(url, init),
promiseTimeout(
self.LibResilientConfig.defaultPluginTimeout,
false,
`LibResilient request using ${plugin.name} timed out after ${self.LibResilientConfig.defaultPluginTimeout}ms.`
)
])
}
/**
* 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 (i=0; i<self.LibResilientPlugins.length; i++) {
if (typeof self.LibResilientPlugins[i][call] === 'function') {
self.log('service-worker', 'Calling plugin ' + self.LibResilientPlugins[i].name + '.' + call + '()')
// call it
// TODO: check if args is an Array?
return self.LibResilientPlugins[i][call].apply(null, args)
}
}
}
/**
* Cycles through all the plugins, in the order they got registered,
* and returns a Promise resolving to a Response in case any of the plugins
* was able to get the resource
*
* request - string containing the URL we want to fetch
* clientId - string containing the clientId of the requesting client
* useStashed - use stashed resources; if false, only pull resources from live sources
* doStash - stash resources once fetched successfully; if false, do not stash pulled resources automagically
* stashedResponse - TBD
*/
let getResourceThroughLibResilient = (request, clientId, useStashed=true, doStash=true, stashedResponse=null) => {
// clean the URL, removing any fragment identifier
var url = request.url.replace(/#.+$/, '');
// get the init object from Request
var init = initFromRequest(request)
// 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({
state: "error",
fetchError: error.toString()
})
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
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
// TODO: perhaps don't use the `request` again? some wrapper?
getResourceThroughLibResilient(request, clientId, false, true, response.clone()).catch((e)=>{
self.log('service-worker', 'background no-stashed fetch failed for:', url);
})
// 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)
self.clients.get(reqInfo.clientId).then((client)=>{
if (client !== null) {
try {
client.postMessage({
url: url,
fetchedDiffers: true
})
} catch(err) {
self.log("service-worker", `postMessage failed for client: ${client}\n- Error message: ${err}`)
}
}
})
// TODO: this should probably modify doStash?
}
}
// do we want to stash?
if (doStash) {
// find the first stashing plugin
for (i=0; i<self.LibResilientPlugins.length; i++) {
if (typeof self.LibResilientPlugins[i].stash === 'function') {
// ok, now we're in business
var hdrs = '\n+-- headers:'
response.headers.forEach((v, k)=>{
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', "LibResilient also failed completely: ", err,
'\n+-- URL : ' + url)
// cleanup
reqInfo.update({
state: "error",
fetchError: err.toString()
})
// this is very naïve and should in fact be handled
// inside the relevant plugin, probably
// TODO: is this even needed?
reqInfo.update({method: null})
decrementActiveFetches(clientId)
// rethrow
throw err
})
}
/* ========================================================================= *\
|* === Setting up the event handlers === *|
\* ========================================================================= */
self.addEventListener('install', event => {
// TODO: Might we want to have a local cache?
// "COMMIT_UNKNOWN" will be replaced with commit ID
self.log('service-worker', "0. Installed LibResilient Service Worker (commit: COMMIT_UNKNOWN).");
// TODO: should we do some plugin initialization here?
});
self.addEventListener('activate', event => {
self.log('service-worker', "1. Activated LibResilient Service Worker (commit: COMMIT_UNKNOWN).");
// TODO: should we do some plugin initialization here?
});
self.addEventListener('fetch', event => {
// 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*
self.clients
.get(clientId)
.then((client)=>{
if (client !== null) {
try {
client.postMessage({
clientId: clientId,
plugins: self.LibResilientPlugins.map((p)=>{return p.name}),
serviceWorker: 'COMMIT_UNKNOWN'
})
} catch(err) {
self.log("service-worker", `postMessage failed for client: ${client}\n- Error message: ${err}`)
}
}
})
}
// 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+-- 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; current origin: ' + self.location.origin)
return void event.respondWith(fetch(event.request));
}
// Non-GET requests go through a regular fetch()
if (event.request.method !== 'GET') {
return void event.respondWith(fetch(event.request));
}
// GET requests to our own domain that are *not* #libresilient-info requests
// get handled by plugins in case of an error
return void event.respondWith(getResourceThroughLibResilient(event.request, clientId))
});
/**
* assumptions to be considered:
* every message contains clientId (so that we know where to respond if/when we need to)
*/
self.addEventListener('message', (event) => {
// 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)
}
}
});