diff --git a/__tests__/service-worker/service-worker.test.js b/__tests__/service-worker/service-worker.test.js index 530e831..7206a98 100644 --- a/__tests__/service-worker/service-worker.test.js +++ b/__tests__/service-worker/service-worker.test.js @@ -54,6 +54,7 @@ class FetchEvent extends Event { } } + beforeAll(async ()=>{ // default mocked response data @@ -66,6 +67,7 @@ beforeAll(async ()=>{ 'Last-Modified': 'TestingLastModifiedHeader', 'ETag': 'TestingETagHeader' } + } // get a Promise resolvint to a mocked Response object built based on supplied data window.getMockedResponse = (url, init, response_data={}) => { @@ -91,6 +93,7 @@ beforeAll(async ()=>{ ); return Promise.resolve(response); } + // get a mocked fetch()-like function that returns a Promise resolving to the above window.getMockedFetch = (response_data={}) => { return (url, init)=>{ @@ -98,6 +101,66 @@ beforeAll(async ()=>{ } } + // prepare a mocked transport plugin, returning a spied-on fetch()-like function + window.prepareMockedTransportConfigJSON = (plugin_name, config_data, stale=false) => { + // by default not stale + let date = new Date().toUTCString() + // very stale if need be + if (stale === true) { + date = new Date(0).toUTCString() + } + // prepare the function + let mockedFetch = spy(window.getMockedFetch({ + data: JSON.stringify(config_data), + headers: { + Date: date + } + })) + // create the plugin + window.LibResilientPluginConstructors.set(plugin_name, ()=>{ + return { + name: plugin_name, + description: 'Resolve with config data (pretending to be fetch).', + version: '0.0.1', + fetch: mockedFetch + } + }) + return mockedFetch + } + + /** + * caching a mocked config.json + */ + window.cacheMockedConfigJSON = async (cache_name, data, stale=false) => { + // prepare the data + let config_data = { + data: JSON.stringify(data) + } + // do we want a stale cache? + if (stale === true) { + config_data.headers = { + // very stale date + 'Date': new Date(0).toUTCString() + } + } else { + config_data.headers = { + // current date + 'Date': new Date().toUTCString() + } + } + // cache it in the relevant cache + return await caches + .open(cache_name) + .then(async (cache)=>{ + await cache + .put( + self.config_url, + await window + .getMockedResponse(config_url, {}, config_data) + ) + }) + } + /* * prototype of the plugin init object */ @@ -105,21 +168,16 @@ beforeAll(async ()=>{ name: 'cache' } - /* - * mocking our own ServiceWorker API, sigh! - * https://github.com/denoland/deno/issues/5957#issuecomment-985494969 - */ - window.registration = { - scope: "https://test.resilient.is/", - unregister: ()=>{} - } - /* * mocking caches.match() * https://developer.mozilla.org/en-US/docs/Web/API/CacheStorage/match#browser_compatibility */ - caches.match = async (url) => { - let cache = await caches.open('v1') + caches.match = async (url, options={}) => { + let cn = 'v1' + if ('cacheName' in options) { + cn = options.cacheName + } + let cache = await caches.open(cn) return cache.match(url) } @@ -210,14 +268,15 @@ beforeAll(async ()=>{ } // wait for caching of a URL, looped up to `tries` times - window.waitForCacheAction = (url, action="add", tries=100) => { - if (action != "add" && action != "remove") { - throw new Error('waitForCacheAction()\'s action parameter can only be "add" or "remove".') + window.waitForCacheAction = (url, cache_name, action="add", tries=100) => { + if (action != "add" && action != "remove" && action != "update") { + throw new Error('waitForCacheAction()\'s action parameter can only be "add", "remove", or "update".') } - console.log('*** WAITING FOR CACHE ACTION:', action, '\n - url:', url) + console.log(`*** WAITING FOR CACHE (${cache_name}) ACTION:`, action, '\n - url:', url) return new Promise(async (resolve, reject)=>{ // get the cache object - let cache = await caches.open('v1') + let cache = await caches.open(cache_name) + let last_result = false // try to match until we succeed, or run out of tries for (let i=0; i{ // > before the end of the test. return resolve(await cache_result.text()) } + + // waiting for cache content to be updated? + } else if (action === 'update') { + // we expect something to have already been in cache + if (cache_result === undefined) { + return reject('Nothing was in cache already, which is unexpected') + } + // save the "old" data + if (last_result === false) { + last_result = await cache_result.text() + // we are waiting for an update of the content + } else { + let cur = await cache_result.text() + if (last_result !== cur) { + return resolve(cur) + } + } + // waiting for content to be removed from cache? } else if (action === "remove") { if (cache_result === undefined) { @@ -278,18 +355,34 @@ beforeAll(async ()=>{ * and make sure window.init is clean and not modified by previous tests */ beforeEach(async ()=>{ + window.config_url = window.location.origin + 'config.json' window.fetch = spy(window.getMockedFetch()) + /* + * mocking our own ServiceWorker API, sigh! + * https://github.com/denoland/deno/issues/5957#issuecomment-985494969 + */ + window.registration = { + scope: "https://test.resilient.is/", + unregister: spy(()=>{}) + } window.init = { ...window.initPrototype } // clear the caches await caches .has('v1') - .then(async (hasv1) => { - if (hasv1) { + .then(async (hasit) => { + if (hasit) { await caches.delete('v1') } }) + await caches + .has('v1:verified') + .then(async (hasit) => { + if (hasit) { + await caches.delete('v1:verified') + } + }) // make sure we're starting with a clean slate in LibResilientPluginConstructors window.LibResilientPluginConstructors = new Map() // keeping track of whether the SW got installed @@ -337,15 +430,6 @@ describe('service-worker', async () => { window.test_id = 0 - it("should set-up LibResilientPlugins", async () => { - // we cannot import the same module multiple times: - // https://github.com/denoland/deno/issues/6946 - // - // ...so we have to use a query-param hack, sigh - await import("../../service-worker.js?" + window.test_id); - assert(self.LibResilientPlugins instanceof Array) - }) - it("should use default LibResilientConfig values when config.json is missing", async () => { let mock_response_data = { @@ -368,7 +452,76 @@ describe('service-worker', async () => { assertSpyCalls(self.fetch, 1) }) - it("should use default LibResilientConfig values when config.json not valid JSON", async () => { + it("should throw a meaningful exception when an unhandled exception occurs during initialization", async () => { + + // this will generate an exception on line where a new Map() + // is created from self.LibResilientPluginConstructors + self.LibResilientPluginConstructors = true + + await import("../../service-worker.js?" + window.test_id); + + // the a failed install event handler in the service worker code + // will run self.registration.unregister() to not leave the site in a broken state + await self.dispatchEvent(new Event('install')) + assertSpyCalls(self.registration.unregister, 1) + }) + + it("should use default LibResilientConfig values when fetching config.json throws an exception", async () => { + + window.fetch = spy(() => { + throw new Error('Testing exception') + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, "object") + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) + assertEquals(self.LibResilientConfig.normalizeQueryParams, true) + assertEquals(self.LibResilientConfig.useMimeSniffingLibrary, false) + assertSpyCalls(self.fetch, 1) + }) + + it("should use default LibResilientConfig values when fetching config.json returns undefined", async () => { + + window.fetch = spy(() => { + return undefined; + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertEquals(typeof self.LibResilientConfig, "object") + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) + assertEquals(self.LibResilientConfig.normalizeQueryParams, true) + assertEquals(self.LibResilientConfig.useMimeSniffingLibrary, false) + assertSpyCalls(self.fetch, 1) + }) + + it("should fail if default LibResilientConfig values are invalid and fetched config.json is not valid JSON", async () => { + + let mock_response_data = { + data: "Not JSON" + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + self.LibResilientConfig = {} + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + assertSpyCalls(self.fetch, 1) + assertSpyCalls(self.registration.unregister, 1) + }) + + it("should use default LibResilientConfig values when the fetched config.json is not valid JSON", async () => { let mock_response_data = { data: "Not JSON" @@ -488,7 +641,7 @@ describe('service-worker', async () => { assertSpyCalls(self.fetch, 1) }) - it("should use config values from a valid fetched config.json file, caching it", async () => { + it("should use config values from a valid fetched config.json file, caching it in both caches (v1, v1:verified)", async () => { let mock_response_data = { data: JSON.stringify({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000, normalizeQueryParams: false, useMimeSniffingLibrary: true}) } @@ -506,11 +659,12 @@ describe('service-worker', async () => { assertEquals(self.LibResilientConfig.useMimeSniffingLibrary, true) assertSpyCalls(self.fetch, 1) - // cacheConfigJSON() is called asynchronously in the Service Worker, - // if we don't make sure that the caching has completed, we will get an error. - // so we wait until config.json is cached, and use that to verify it is in fact cached assertEquals( - await window.waitForCacheAction(window.location.origin + 'config.json'), + await window.waitForCacheAction(self.config_url, 'v1'), + mock_response_data.data + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), mock_response_data.data ); }) @@ -741,262 +895,582 @@ describe('service-worker', async () => { assertEquals(self.LibResilientPlugins[1].uses[0].uses.length, 0) }) - it("should use a cached valid config.json file, with no fetch happening", async () => { - - // prepare the config request/response - let mock_response_data = { - data: JSON.stringify({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000}) - } - var config_url = window.location.origin + 'config.json' - - // get the response containing config to cache - var config_response = await window.getMockedResponse(config_url, {}, mock_response_data) - - // cache it once you get it - await caches - .open('v1') - .then(async (cache)=>{ - await cache - .put( - config_url, - await window.getMockedResponse(config_url, {}, mock_response_data) - ) - }) + /* + * testing config.json fetching, verification, and caching + */ + + it("should use a valid config.json from v1 cache, with no fetch happening, caching it in v1:verified cache", async () => { + // v1 cache data + let v1_data = {loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000} + // cache it in v1 cache, not stale + await self.cacheMockedConfigJSON("v1", v1_data, false) // service worker is a go! await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() + // no fetch + assertSpyCalls(self.fetch, 0) + + // config applied assertEquals(typeof self.LibResilientConfig, 'object') assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) - assertSpyCalls(self.fetch, 0) + + // in both caches we expect the contents of v1 cache + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1_data), + "v1:verified cache has incorrect config.json data" + ); }) - it("should use a stale cached valid config.json file without a fetch, then retrieve and cache a fresh config.json using the configured plugins", async () => { - - // this does not change - var config_url = window.location.origin + 'config.json' - - // prepare the stale config request/response - let mock_response_data = { - data: JSON.stringify({loggedComponents: ['service-worker', 'cache', 'fetch'], plugins: [{name: "fetch"},{name: "cache"}], defaultPluginTimeout: 5000}), - headers: { - // very stale date - 'Date': new Date(0).toUTCString() - } - } - - // cache it once you get it - await caches - .open('v1') - .then(async (cache)=>{ - await cache - .put( - config_url, - await window.getMockedResponse(config_url, {}, mock_response_data) - ) - let resp = await cache - .match(config_url) - console.log(resp) - console.log(await resp.text()) - }) - - // prepare the fresh config request/response - let mock_response_data2 = { - data: JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 2000}), - headers: { - // very stale date - 'Date': new Date(0).toUTCString() - } - } - - // we need to be able to spy on the function that "fetches" the config - let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) - window.LibResilientPluginConstructors.set('fetch', ()=>{ - return { - name: 'fetch', - description: 'Resolve with config data (pretending to be fetch).', - version: '0.0.1', - fetch: resolveConfigFetch - } - }) + it("should use a valid config.json from v1:verified cache when not available in v1, with no fetch happening, caching it in v1 cache", async () => { + // v1:verified cache data + let v1verified_data = {loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 6000} + // cache it in v1 cache, not stale + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, false) // service worker is a go! await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() - // verify current config (the one from the pre-cached stale `config.json`) - assertEquals(typeof self.LibResilientConfig, 'object') - assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) - assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) - assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache', 'fetch']) + // no fetch assertSpyCalls(self.fetch, 0) - assertSpyCalls(resolveConfigFetch, 1) - // wait until verify the *new* config got cached - // running waitForCacheAdd only once might not be enough, as the cache - // already contained config.json! - // - // we have try to get it a few times, but limit how many times we try - // so as not to end up in a forever loop - for (let i=0; i<100; i++) { - // did we get the new config? - if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { - // we did! we're done - return true; - } - } - fail('New config failed to cache, apparently!') + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) + + // in both caches we expect the contents of v1 cache + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1verified_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); }) - it("should use a stale cached valid config.json file without a fetch; invalid config.json retrieved using the configured plugins should not be cached", async () => { + it("should use a valid config.json from v1:verified cache when the one in v1 is invalid (structurally), with no fetch happening, caching it in v1 cache", async () => { + // v1 cached data invalid (invalid structure) + let v1_data = {loggedComponentsInvalid: ['service-worker', 'cache'], pluginsInvalid: [{name: "cache"}], defaultPluginTimeoutInvalid: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, false) - // this does not change - var config_url = window.location.origin + 'config.json' - - // prepare the stale config request/response - let mock_response_data = { - data: JSON.stringify({loggedComponents: ['service-worker', 'cache', 'resolve-config'], plugins: [{name: "cache"}, {name: "resolve-config"}], defaultPluginTimeout: 5000}), - headers: { - // very stale date - 'Date': new Date(0).toUTCString() - } - } - - // cache it once you get it - await caches - .open('v1') - .then(async (cache)=>{ - await cache - .put( - config_url, - await window.getMockedResponse(config_url, {}, mock_response_data) - ) - let resp = await cache - .match(config_url) - console.log(resp) - console.log(await resp.text()) - }) - - // prepare the fresh invalid config request/response - let mock_response_data2 = { - data: JSON.stringify({loggedComponentsInvalid: ['service-worker', 'resolve-config'], pluginsInvalid: [{name: "resolve-config"}], defaultPluginTimeoutInvalid: 2000}), - headers: { - // very stale date - 'Date': new Date(0).toUTCString() - } - } - - // we need to be able to spy on the function that "fetches" the config - let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) - window.LibResilientPluginConstructors.set('resolve-config', ()=>{ - return { - name: 'resolve-config', - description: 'Resolve with config data.', - version: '0.0.1', - fetch: resolveConfigFetch - } - }) + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, false) // service worker is a go! await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() - // verify current config (the one from the pre-cached stale `config.json`) - assertEquals(typeof self.LibResilientConfig, 'object') - assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) - assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}, {name: "resolve-config"}]) - assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache', 'resolve-config']) + // no fetch assertSpyCalls(self.fetch, 0) - assertSpyCalls(resolveConfigFetch, 1) - // waiting for potential caching of the "new" config - for (let i=0; i<100; i++) { - // did we get the new config? - if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { - // we did! that's a paddling! - fail('New config failed to cache, apparently!') - } - } + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) + + // in both caches we expect the contents of v1 cache; in v1 the contents need to be updated + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1verified_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); }) - it("should use a stale cached valid config.json file without a fetch; valid config.json retrieved using the configured plugins other than fetch should not be cached", async () => { + it("should use a valid config.json from v1:verified cache when the one in v1 is invalid (syntactically), with no fetch happening, caching it in v1 cache", async () => { + // v1 cached data invalid (not JSON) + let v1_data = "NOT JSON" + await self.cacheMockedConfigJSON("v1", v1_data, false) - // this does not change - var config_url = window.location.origin + 'config.json' - - // prepare the stale config request/response - let mock_response_data = { - data: JSON.stringify({loggedComponents: ['service-worker', 'resolve-config'], plugins: [{name: "resolve-config"}], defaultPluginTimeout: 5000}), - headers: { - // very stale date - 'Date': new Date(0).toUTCString() - } - } - - // cache it once you get it - await caches - .open('v1') - .then(async (cache)=>{ - await cache - .put( - config_url, - await window.getMockedResponse(config_url, {}, mock_response_data) - ) - let resp = await cache - .match(config_url) - console.log(resp) - console.log(await resp.text()) - }) - - // prepare the fresh invalid config request/response - let mock_response_data2 = { - data: JSON.stringify({loggedComponents: ['service-worker', 'resolve-config', 'cache'], plugins: [{name: "resolve-config"}, {name: "cache"}], defaultPluginTimeout: 2000}), - headers: { - // very stale date - 'Date': new Date(0).toUTCString() - } - } - - // we need to be able to spy on the function that "fetches" the config - let resolveConfigFetch = spy(window.getMockedFetch(mock_response_data2)) - window.LibResilientPluginConstructors.set('resolve-config', ()=>{ - return { - name: 'resolve-config', - description: 'Resolve with config data.', - version: '0.0.1', - fetch: resolveConfigFetch - } - }) + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, false) // service worker is a go! await import("../../service-worker.js?" + window.test_id); await self.dispatchEvent(new Event('install')) await self.waitForSWInstall() - // verify current config (the one from the pre-cached stale `config.json`) + // no fetch + assertSpyCalls(self.fetch, 0) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) + + // in both caches we expect the contents of v1 cache; in v1 the contents need to be updated + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1verified_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should use a valid config.json from v1:verified cache when the one in v1 is invalid (unavailable plugins), with no fetch happening, caching it in v1 cache", async () => { + // v1 cached data valid + let v1_data = {loggedComponents: ['service-worker', 'no-such-plugin'], plugins: [{name: "no-such-plugin"}], defaultPluginTimeout: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, false) + + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // no fetch + assertSpyCalls(self.fetch, 0) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) + + // in both caches we expect the contents of v1 cache; in v1 the contents need to be updated + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1verified_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should use a stale config.json from v1 cache, with no fetch happening, caching it in v1:verified cache", async () => { + // v1 cached data valid + let v1_data = {loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, true) // stale! + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // no fetch + assertSpyCalls(self.fetch, 0) + + // config applied assertEquals(typeof self.LibResilientConfig, 'object') assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) - assertEquals(self.LibResilientConfig.plugins, [{name: "resolve-config"}]) - assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'resolve-config']) - assertSpyCalls(self.fetch, 0) - assertSpyCalls(resolveConfigFetch, 1) + assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) - // waiting for potential caching of the "new" config - for (let i=0; i<100; i++) { - // did we get the new config? - if (await window.waitForCacheAction(config_url) === mock_response_data2.data) { - // we did! that's a paddling - fail('New config failed to cache, apparently!') - } - } + // in both caches we expect the contents of v1 cache; in v1 the contents need to be updated + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1_data), + "v1:verified cache has incorrect config.json data" + ); }) + it("should use a stale config.json from v1 cache, caching it in v1:verified cache; then try to fetch a fresh version, caching it if valid in v1 cache", async () => { + // v1 cached data valid + let v1_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "mocked-fetch"}, {name: 'cache'}], defaultPluginTimeout: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, true) // stale! + + let fetch_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "cache"}, {name: "mocked-fetch"}], defaultPluginTimeout: 7000} + let mockedFetch = self.prepareMockedTransportConfigJSON('mocked-fetch', fetch_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // no fetch + assertSpyCalls(mockedFetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.plugins, [{name: "mocked-fetch"}, {name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'mocked-fetch', 'cache']) + + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1', 'update'), + JSON.stringify(fetch_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should use a stale config.json from v1 cache, then try to fetch a fresh version, ignoring it if invalid (structurally) and caching the v1 version in v1:verified cache", async () => { + // v1 cached data valid + let v1_data = {loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, true) // stale! + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // no fetch + assertSpyCalls(self.fetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch']) + + // v1 cache should *not* be updated + try { + await window.waitForCacheAction(self.config_url, 'v1', 'update') + } catch (e) { + assertEquals(e, 'Ran out of tries') + } + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should use a stale config.json from v1 cache, then try to fetch a fresh version, ignoring it if invalid (unavailable plugins) and caching the v1 version in v1:verified cache", async () => { + // v1 cached data valid + let v1_data = {loggedComponents: ['service-worker', 'mocked-fetch'], plugins: [{name: "mocked-fetch"}], defaultPluginTimeout: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, true) // stale! + + let fetch_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'no-such-plugin'], plugins: [{name: "mocked-fetch"}, {name: 'no-such-plugin'}], defaultPluginTimeout: 7000} + let mockedFetch = self.prepareMockedTransportConfigJSON('mocked-fetch', fetch_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // no fetch + assertSpyCalls(mockedFetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.plugins, [{name: "mocked-fetch"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'mocked-fetch']) + + // v1 cache should *not* be updated + try { + await window.waitForCacheAction(self.config_url, 'v1', 'update') + } catch (e) { + assertEquals(e, 'Ran out of tries') + } + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should use a stale config.json from v1:verified cache; then try to fetch a fresh version, caching it if valid in v1 cache", async () => { + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "mocked-fetch"}, {name: 'cache'}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, true) // stale! + + let fetch_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "cache"}, {name: "mocked-fetch"}], defaultPluginTimeout: 7000} + let mockedFetch = self.prepareMockedTransportConfigJSON('mocked-fetch', fetch_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // fetch + assertSpyCalls(mockedFetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "mocked-fetch"}, {name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'mocked-fetch', 'cache']) + + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1', 'update'), + JSON.stringify(fetch_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should use a stale config.json from v1:verified cache, then try to fetch a fresh version, ignoring it if invalid (structurally) and caching the v1:verified version in v1 cache", async () => { + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, true) // stale! + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // fetch + assertSpyCalls(self.fetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch']) + + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1verified_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should use a stale config.json from v1:verified cache, then try to fetch a fresh version, ignoring it if invalid (unavailable plugins) and caching the v1:verified version in v1 cache", async () => { + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'mocked-fetch'], plugins: [{name: "mocked-fetch"}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, true) // stale! + + let fetch_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'no-such-plugin'], plugins: [{name: "mocked-fetch"}, {name: 'no-such-plugin'}], defaultPluginTimeout: 7000} + let mockedFetch = self.prepareMockedTransportConfigJSON('mocked-fetch', fetch_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // mocked fetch + assertSpyCalls(mockedFetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "mocked-fetch"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'mocked-fetch']) + + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1verified_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should ignore invalid (structurally) config from v1 cache and use a stale config.json from v1:verified cache; then try to fetch a fresh version, caching it if valid in v1 cache", async () => { + // v1 cached data invalid (invalid structure) + let v1_data = {loggedComponentsInvalid: ['service-worker', 'cache'], pluginsInvalid: [{name: "cache"}], defaultPluginTimeoutInvalid: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, false) + + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "mocked-fetch"}, {name: 'cache'}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, true) // stale! + + let fetch_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "cache"}, {name: "mocked-fetch"}], defaultPluginTimeout: 7000} + let mockedFetch = self.prepareMockedTransportConfigJSON('mocked-fetch', fetch_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // fetch + assertSpyCalls(mockedFetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "mocked-fetch"}, {name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'mocked-fetch', 'cache']) + + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1', 'update'), + JSON.stringify(fetch_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should ignore invalid (syntactically) config from v1 cache and use a stale config.json from v1:verified cache; then try to fetch a fresh version, caching it if valid in v1 cache", async () => { + // v1 cached data invalid (not JSON) + let v1_data = "NOT JSON" + await self.cacheMockedConfigJSON("v1", v1_data, false) + + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "mocked-fetch"}, {name: 'cache'}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, true) // stale! + + let fetch_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "cache"}, {name: "mocked-fetch"}], defaultPluginTimeout: 7000} + let mockedFetch = self.prepareMockedTransportConfigJSON('mocked-fetch', fetch_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // fetch + assertSpyCalls(mockedFetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "mocked-fetch"}, {name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'mocked-fetch', 'cache']) + + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1', 'update'), + JSON.stringify(fetch_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should ignore invalid (unavailable plugins) config from v1 cache and use a stale config.json from v1:verified cache; then try to fetch a fresh version, caching it if valid in v1 cache", async () => { + // v1 cached data invalid (unavailable plugin) + let v1_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'no-such-plugin'], plugins: [{name: "mocked-fetch"}, {name: 'no-such-plugin'}], defaultPluginTimeout: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, false) + + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "mocked-fetch"}, {name: 'cache'}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, true) // stale! + + let fetch_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "cache"}, {name: "mocked-fetch"}], defaultPluginTimeout: 7000} + let mockedFetch = self.prepareMockedTransportConfigJSON('mocked-fetch', fetch_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // fetch + assertSpyCalls(mockedFetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "mocked-fetch"}, {name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'mocked-fetch', 'cache']) + + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1', 'update'), + JSON.stringify(fetch_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + it("should ignore invalid config from v1 cache and use a stale config.json from v1:verified cache; then try to fetch a fresh version, ignoring it if invalid", async () => { + // v1 cached data invalid (invalid structure) + let v1_data = {loggedComponentsInvalid: ['service-worker', 'cache'], pluginsInvalid: [{name: "cache"}], defaultPluginTimeoutInvalid: 5000} + await self.cacheMockedConfigJSON("v1", v1_data, false) + + // v1:verified cached data valid + let v1verified_data = {loggedComponents: ['service-worker', 'mocked-fetch', 'cache'], plugins: [{name: "mocked-fetch"}, {name: 'cache'}], defaultPluginTimeout: 6000} + await self.cacheMockedConfigJSON("v1:verified", v1verified_data, true) // stale! + + let fetch_data = "NOT JSON" + let mockedFetch = self.prepareMockedTransportConfigJSON('mocked-fetch', fetch_data, false) + + // service worker is a go! + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + // fetch + assertSpyCalls(mockedFetch, 1) + + // config applied + assertEquals(typeof self.LibResilientConfig, 'object') + assertEquals(self.LibResilientConfig.defaultPluginTimeout, 6000) + assertEquals(self.LibResilientConfig.plugins, [{name: "mocked-fetch"}, {name: "cache"}]) + assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'mocked-fetch', 'cache']) + + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1'), + JSON.stringify(v1verified_data), + "v1 cache has incorrect config.json data" + ); + assertEquals( + await window.waitForCacheAction(self.config_url, 'v1:verified'), + JSON.stringify(v1verified_data), + "v1:verified cache has incorrect config.json data" + ); + }) + + /* + * end of testing config.json fetching, verification, and caching + */ + it("should ignore failed fetch by first configured plugin if followed by a successful fetch by a second one", async () => { window.LibResilientConfig = { plugins: [{ @@ -1302,7 +1776,7 @@ describe('service-worker', async () => { // cached assertEquals( JSON.parse( - await window.waitForCacheAction(window.location.origin + 'test.json')), + await window.waitForCacheAction(window.location.origin + 'test.json', 'v1')), { test: "success" } ); }); @@ -1444,7 +1918,7 @@ describe('service-worker', async () => { // let's see if it got added to cache assertEquals( - JSON.parse(await window.waitForCacheAction(window.location.origin + 'test.json')), + JSON.parse(await window.waitForCacheAction(window.location.origin + 'test.json', 'v1')), { test: "success" } ); }); @@ -1560,7 +2034,7 @@ describe('service-worker', async () => { // let's see if it got added to cache assertEquals( - JSON.parse(await window.waitForCacheAction(window.location.origin + 'test.json')), + JSON.parse(await window.waitForCacheAction(window.location.origin + 'test.json', 'v1')), { test: "success" } ); @@ -1574,7 +2048,7 @@ describe('service-worker', async () => { // let's see if it got removed from cache assertEquals( - await window.waitForCacheAction(window.location.origin + 'test.json', "remove"), + await window.waitForCacheAction(window.location.origin + 'test.json', 'v1', "remove"), undefined ); }); diff --git a/service-worker.js b/service-worker.js index b19a8fb..dc33d9d 100644 --- a/service-worker.js +++ b/service-worker.js @@ -2,11 +2,6 @@ * LibResilient Service Worker. */ -// initialize the LibResilientPlugins array -if (!Array.isArray(self.LibResilientPlugins)) { - self.LibResilientPlugins = new Array() -} - // initialize the LibResilientConfig array // // this also sets some sane defaults, @@ -200,6 +195,11 @@ self.guessMimeType = async function(ext, content) { * 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') @@ -242,13 +242,13 @@ let verifyConfigData = (cdata) => { * configURL - url of the config file * cresponse - response we're caching */ -let cacheConfigJSON = async (configURL, cresponse) => { +let cacheConfigJSON = async (configURL, cresponse, use_cache) => { try { - var cache = await caches.open('v1') + var cache = await caches.open(use_cache) await cache.put(configURL, cresponse) - self.log('service-worker', 'config cached.') + self.log('service-worker', `config cached in cache: ${use_cache}.`) } catch(e) { - self.log('service-worker', `failed to cache config: ${e}`) + self.log('service-worker', `failed to cache config in cache ${use_cache}: ${e}`) } } @@ -277,7 +277,141 @@ let getConfigJSON = async (cresponse) => { } -// flag signifying the SW has been initialized already +/** + * 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() + + // 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`) + + // 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) && (pluginConfig !== undefined) ) + + } + + // finally -- 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!') + } + } + + // we're good! + return true; + +} + + +// flag signifying if the SW has been initialized already var initDone = false // load the plugins @@ -292,170 +426,152 @@ let initServiceWorker = async () => { try { // we'll need this later - var cresponse = null + let cresponse = null + let config = null - // get the config - // // 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 - try { - - // config.json URL - var configURL = self.registration.scope + "config.json" - - // get the config data from cache - cresponse = await caches.match(configURL) - var cdata = await getConfigJSON(cresponse) - - // did it work? - if (cdata != false) { - // we need to know if the config was already cached - self.log('service-worker', `valid config file retrieved from cache.`) - - // cache failed to deliver - } else { - - // try fetching directly from the main domain - self.log('service-worker', `config file not found in cache, fetching.`) - var cresponse = await fetch(configURL) - cdata = await getConfigJSON(cresponse) - - // did that work? - if (cdata != false) { - // cache it, asynchronously - cacheConfigJSON(configURL, cresponse) - - } else { - // we ain't got nothing useful -- just set cdata to an empty object - cdata = {} - self.log('service-worker', 'ignoring invalid config, using defaults.') - } - } - - // merge configs - self.LibResilientConfig = {...self.LibResilientConfig, ...cdata} - self.log('service-worker', 'config loaded.') - - } catch (e) { - self.log('service-worker', 'config loading failed, using defaults; error:', e) - } + // TODO: this should probably be configurable somehow + let configURL = self.registration.scope + "config.json" - // first let's deal with the easy part -- do we want to use MIME type guessing based on content? - if (self.LibResilientConfig.useMimeSniffingLibrary === true) { - // we do! load the external lib - self.importScripts(`./lib/file-type.js`) - 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!') - } - } + // 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() - // copy of the plugins config - // we need to work on it so that self.LibResilientConfig.plugins remains unmodified - // in case we need it later (for example, when re-loading the config) - var pluginsConfig = [...self.LibResilientConfig.plugins] + // 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) - // this is the stash for plugins that need dependencies instantiated first - var dependentPlugins = new Array() + // caches to try: temp cache, main cache + let available_caches = ['v1', 'v1:verified'] - // only now load the plugins (config.json could have changed the defaults) - while (pluginsConfig.length > 0) { + // keep track + let config_executed = false + let use_cache = false + + do { - // get the first plugin config from the array - let pluginConfig = pluginsConfig.shift() - self.log('service-worker', `handling plugin type: ${pluginConfig.name}`) + // init config data var + let cdata = false - // 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`) - } + // where are we getting the config.json from this time? + // we eitehr get a string (name of a cache), or undefined (signifying need for fetch()) + use_cache = available_caches.shift() - // 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`) + try { - // 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 - 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) + // cache? + if ( typeof use_cache === 'string' ) { + self.log('service-worker', `retrieving config.json from cache: ${use_cache}.`) + cresponse = await caches.match(configURL, {cacheName: use_cache}) + + // bail early if we got nothing + if (cresponse === undefined) { + self.log('service-worker', `config.json not found in cache: ${use_cache}.`) + continue } - continue; + + // regular fetch + // (we don't have any plugin transports at this point, obviously...) + } else { + self.log('service-worker', `retrieving config.json using fetch().`) + cresponse = await fetch(configURL) } - // instantiate the plugin - let plugin = LibResilientPluginConstructors.get(pluginConfig.name)(self, pluginConfig) - self.log('service-worker', `${pluginConfig.name}: instantiated`) + // extract the JSON and verify it + cdata = await getConfigJSON(cresponse) - // 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; - } + // exception? no bueno! + } catch(e) { + cdata = false + self.log('service-worker', `exception when trying to retrieve config.json: ${e.message}`) + } - // 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}`) + // do we have anything to work with? + if (cdata === false) { - // 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() + // cached config.json was invalid; no biggie, try another cache, or fetch() + if (typeof use_cache === "string") { + self.log('service-worker', `cached config.json is not valid; cache: ${use_cache}`) continue + + // if that was a fetch() config, we need to run to defaults! + } else { + self.log('service-worker', `fetched config.json is not valid; using defaults`) + // set an empty object, this will in effect deploy pure defaults + cdata = {} + // clear cresponse which will indicate later on + // that we did not use data from any response, cache nor fetch + cresponse = false } - // 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 + // we good! + } else { + self.log('service-worker', `valid-looking config.json retrieved.`) + } + + // merge configs + 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 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) ) + // if we're using the defaults, and yet loading of the config failed + // something is massively wrong + if ( ( cresponse === false ) && ( 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_cache === "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_cache === "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_cache === undefined) && (cresponse !== false) ) { + self.log('service-worker', `successfully loaded config.json; caching in cache: 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 @@ -463,7 +579,7 @@ let initServiceWorker = async () => { initDone = true; // regardless how we got the config file, if it's older than 24h... - if ( (new Date()) - Date.parse(cresponse.headers.get('date')) > 86400000) { + 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`) @@ -475,22 +591,28 @@ let initServiceWorker = async () => { var cdata = await getConfigJSON(cresponse) // did that work? - if (cdata != false) { + 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 - // FIXME: this does not currently dive into dependencies! - // FIXME: https://gitlab.com/rysiekpl/libresilient/-/issues/48 - for (let p in cdata.plugins) { - var pname = cdata.plugins[p].name - + 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 + // 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 @@ -499,16 +621,26 @@ let initServiceWorker = async () => { // 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(pname)) { - self.log('service-worker', `config.json was retrieved through plugins other than fetch, but specifies additional plugins (${pname}); ignoring.`) + 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', `config.json successfully retrieved through plugins; caching.`) - // cache it, asynchronously - cacheConfigJSON(configURL, cresponse) + 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") } }) } @@ -517,10 +649,10 @@ let initServiceWorker = async () => { // 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 plugins + // we got an error while initializing the service worker! // better play it safe! self.registration.unregister() - throw e + return false } return true; } @@ -1010,11 +1142,12 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt |* === Setting up the event handlers === *| \* ========================================================================= */ self.addEventListener('install', async (event) => { - await event.waitUntil( - initServiceWorker() - ) - // "COMMIT_UNKNOWN" will be replaced with commit ID - self.log('service-worker', "0. Installed LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); + let init_promise = initServiceWorker() + await event.waitUntil(init_promise) + if (await init_promise === true) { + // "COMMIT_UNKNOWN" will be replaced with commit ID + self.log('service-worker', "0. Installed LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); + } }); self.addEventListener('activate', async event => {