diff --git a/__tests__/service-worker/service-worker.test.js b/__tests__/service-worker/service-worker.test.js index 93b1f49..9aa522a 100644 --- a/__tests__/service-worker/service-worker.test.js +++ b/__tests__/service-worker/service-worker.test.js @@ -232,9 +232,9 @@ beforeAll(async ()=>{ postMessage: window.clients.prototypePostMessage } }, - claim: async () => { + claim: spy(async () => { return undefined - }, + }), // the actual spy function must be possible to reference // but we want spy data per test, so we set it properly in beforeEach() prototypePostMessage: null @@ -443,6 +443,14 @@ describe('service-worker', async () => { window.test_id = 0 + it("should call clients.claim() when activated", async () => { + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + await self.dispatchEvent(new Event('activate')) + assertSpyCalls(window.clients.claim, 1) + }) + it("should use default LibResilientConfig values when config.json is missing", async () => { let mock_response_data = { @@ -1592,6 +1600,246 @@ describe('service-worker', async () => { assertEquals(await response.json(), { test: "success" }) }); + it("should use return a 4xx error directly from the last plugin, regardless of previous plugin errors or rejection", async () => { + window.LibResilientConfig = { + plugins: [{ + name: 'reject-all' + },{ + name: 'error-out' + },{ + name: 'return-418' + }], + loggedComponents: [ + 'service-worker' + ] + } + + let rejectingFetch = spy( + (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: rejectingFetch + } + }) + + let throwingFetch = spy( + (request, init)=>{ throw new Error('error-out throwing an Error for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('error-out', ()=>{ + return { + name: 'error-out', + description: 'Throws.', + version: '0.0.1', + fetch: throwingFetch + } + }) + + let mock_response_data = { + data: JSON.stringify({text: "success"}), + status: 418, + statusText: "Im A Teapot" + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + + window.LibResilientPluginConstructors.set('return-418', ()=>{ + return { + name: 'return-418', + description: 'Return 418 HTTP Error.', + version: '0.0.1', + fetch: fetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent('test.json') + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertSpyCalls(window.fetch, 2); // two, because the first one is for config.json + assertSpyCalls(rejectingFetch, 1); + assertSpyCalls(throwingFetch, 1); + assertSpyCall(window.fetch, 1, { args: [ + "https://test.resilient.is/test.json", + { + cache: undefined, + integrity: undefined, + method: "GET", + redirect: "follow", + referrer: undefined, + }] + }) + assertEquals(response.status, 418) + assertEquals(response.statusText, 'Im A Teapot') + assertEquals(await response.json(), { text: "success" }) + }); + + it("should use return a 4xx error directly from a plugin, regardless of any following plugins", async () => { + window.LibResilientConfig = { + plugins: [{ + name: 'return-418' + },{ + name: 'reject-all' + },{ + name: 'error-out' + }], + loggedComponents: [ + 'service-worker' + ] + } + + let rejectingFetch = spy( + (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: rejectingFetch + } + }) + + let throwingFetch = spy( + (request, init)=>{ throw new Error('error-out throwing an Error for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('error-out', ()=>{ + return { + name: 'error-out', + description: 'Throws.', + version: '0.0.1', + fetch: throwingFetch + } + }) + + let mock_response_data = { + data: JSON.stringify({text: "success"}), + status: 418, + statusText: "Im A Teapot" + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + + window.LibResilientPluginConstructors.set('return-418', ()=>{ + return { + name: 'return-418', + description: 'Return 418 HTTP Error.', + version: '0.0.1', + fetch: fetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent('test.json') + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertSpyCalls(window.fetch, 2); // two, because the first one is for config.json + assertSpyCalls(rejectingFetch, 0); + assertSpyCalls(throwingFetch, 0); + assertSpyCall(window.fetch, 1, { args: [ + "https://test.resilient.is/test.json", + { + cache: undefined, + integrity: undefined, + method: "GET", + redirect: "follow", + referrer: undefined, + }] + }) + assertEquals(response.status, 418) + assertEquals(response.statusText, 'Im A Teapot') + assertEquals(await response.json(), { text: "success" }) + }); + + it("should use treat a 5xx error from a plugin as internal error and try following plugins", async () => { + window.LibResilientConfig = { + plugins: [{ + name: 'return-500' + },{ + name: 'reject-all' + },{ + name: 'error-out' + }], + loggedComponents: [ + 'service-worker' + ] + } + + let rejectingFetch = spy( + (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: rejectingFetch + } + }) + + let throwingFetch = spy( + (request, init)=>{ throw new Error('error-out throwing an Error for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('error-out', ()=>{ + return { + name: 'error-out', + description: 'Throws.', + version: '0.0.1', + fetch: throwingFetch + } + }) + + let mock_response_data = { + data: JSON.stringify({text: "success"}), + status: 500, + statusText: "Internal Server Error" + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + window.LibResilientPluginConstructors.set('return-500', ()=>{ + return { + name: 'return-500', + description: 'Return 500 HTTP Error.', + version: '0.0.1', + fetch: fetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent('test.json') + window.dispatchEvent(fetch_event) + let response = fetch_event.waitForResponse() + assertRejects( async () => { + return await response + }) + // wait for the response to resolve + await response.catch((e)=>{}) + + assertSpyCalls(window.fetch, 2); + assertSpyCalls(rejectingFetch, 1); + assertSpyCalls(throwingFetch, 1); + }); + it("should normalize query params in requested URLs by default", async () => { console.log(self.LibResilientConfig) @@ -2705,7 +2953,7 @@ describe('service-worker', async () => { let response = await fetch_event.waitForResponse() await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) - assertEquals((await response.text()).slice(0, 58), 'Still loading...') + assertEquals((await response.text()).slice(0, 55), 'Still loading') }) it("should not use the still-loading screen when handling a regular request, even if a stashing plugin is configured and enabled and stillLoadingTimeout set to a positive integer", async () => { @@ -2882,4 +3130,110 @@ describe('service-worker', async () => { assertEquals(await response.json(), { test: "success" }) }) + + it("should return a 404 Not Found HTTP response object with an error screen when handling a rejected navigation request", async () => { + window.LibResilientConfig = { + plugins: [{ + name: 'reject-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + + let rejectingFetch = spy( + (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: rejectingFetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent(window.location.origin + 'test.json', {mode: "navigate"}) + window.dispatchEvent(fetch_event) + let response = await fetch_event.waitForResponse() + + assertEquals(response.status, 404) + assertEquals(response.statusText, 'Not Found') + assertEquals(response.headers.get('content-type'), 'text/html') + assertEquals((await response.text()).slice(0, 57), 'Loading failed.') + }) + + it("should not return a 404 Not Found HTTP response object with an error screen when handling a rejected non-navigation request", async () => { + window.LibResilientConfig = { + plugins: [{ + name: 'reject-all' + }], + loggedComponents: [ + 'service-worker' + ] + } + + let rejectingFetch = spy( + (request, init)=>{ return Promise.reject('reject-all rejecting a request for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('reject-all', ()=>{ + return { + name: 'reject-all', + description: 'Reject all requests.', + version: '0.0.1', + fetch: rejectingFetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent(window.location.origin + 'test.json') + window.dispatchEvent(fetch_event) + assertRejects(async ()=>{ await fetch_event.waitForResponse() }) + }) + + it("should not return a 404 Not Found HTTP response object with an error screen when handling a non-navigation request that throws an error", async () => { + window.LibResilientConfig = { + plugins: [{ + name: 'error-out' + }], + loggedComponents: [ + 'service-worker' + ] + } + + let throwingFetch = spy( + (request, init)=>{ throw new Error('error-out throwing an Error for: ' + request); } + ) + + window.LibResilientPluginConstructors.set('error-out', ()=>{ + return { + name: 'error-out', + description: 'Throws.', + version: '0.0.1', + fetch: throwingFetch + } + }) + + await import("../../service-worker.js?" + window.test_id); + await self.dispatchEvent(new Event('install')) + await self.waitForSWInstall() + + let fetch_event = new FetchEvent(window.location.origin + 'test.json') + window.dispatchEvent(fetch_event) + assertRejects(async ()=>{ + await fetch_event.waitForResponse() + }, + Error, + 'error-out throwing an Error for: https://test.resilient.is/test.json' + ) + }) }) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 253de8f..e43ebbe 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -219,3 +219,31 @@ The still-loading screen is *only* displayed when *all* of these conditions are The reason why a stashing plugin needs to be configured and enabled is to avoid loops. Consider a scenario, where a visitor is navigating to a page, and the request is taking very long. The still-loading screen is displayed (by way of the service worker returning the relevant HTML in response to the request). Eventually, the request completes in the background, but the response is discarded due to lack of a stashing plugin. In such a case reloading the page will cause a repeat: request, still-loading screen, request completes in the background (and the result is discarded). The visitor would be stuck in a loop. If a stashing plugin (like `cache`) is enabled, this loop can be expected not to emerge, since the second request would quickly return the cached response. + +## Error handling + +LibResilient's error handling focuses on attempting to "do the right thing". In some cases this means passing a HTTP error response from a plugin directly to the browser to be displayed to the user; in other cases it means ignoring HTTP error response from a plugin so that other plugins can attempt to retrieve a given resource. + +In general: + + - **A response with status code value of `499` or lower is passed immediately to the browser** + no other plugins are used to try to retrieve the content; if a stashing plugin is configured, the response might be cached locally. + + - **A response with status code value of `500` or higher is treated as a plugin error** + if other plugins are configured, they will be used to try to retrieve the content; if a stashing plugin is configured it will not stash that response. + + - **Any exception thrown in a plugin will be caught and treated as a plugin error** + if other plugins are configured, they will be used to try to retrieve the content; there is no response object, so there is nothing to stash. + + - **If a plugin rejects for whatever reason, it is treated as a plugin error** + if other plugins are configured, they will be used to try to retrieve the content; there is no response object, so there is nothing to stash. + +All plugin errors (`5xx` HTTP responses, thrown exceptions, rejections) are logged internally. This data is printed in the console (if `loggedComponents` config field contains `service-worker`), and sent to the client using `Client.postMessage()`, to simplify debugging. + +If all plugins fail in case of a navigate request, a Request object is created with a `404 Not Found` HTTP status, containing a simple HTML error page (similar to the still-loading screen mentioned above) to be displayed to the user. If the request is not a navigate request, the rejected promise is returned directly. + +Mapping plugin errors onto HTTP errors is not always going to be trivial. For example, an IPFS-based transport plugin could in some circumstances return a `404 Not Found` HTTP error, but the `any-of` plugin necessarily has to ignore any HTTP errors it receives from plugins it is configured to use, while waiting for one to potentially return the resource successfully. If all of the configured plugins fail, with different HTTP errors, which one should the `any-of` plugin return itself?.. + +At the same time, returning HTTP errors makes sense, as it allows the browser and the user to properly interpret well-understood errors. So, the `fetch` plugin will return any `4xx` HTTP error it receives, for example, and the service worker will in turn treat that as a successfully completed retrieval and return that to the browser to be displayed to the user. + +Plugin authors should consider this carefully. If in doubt, it's probably better to throw an exception or reject the promise with a meaningful error message, than to try to fit a potentially complex failure mode into the limited and rigit contraints of HTTP error codes. diff --git a/plugins/alt-fetch/index.js b/plugins/alt-fetch/index.js index 124217a..61eba29 100644 --- a/plugins/alt-fetch/index.js +++ b/plugins/alt-fetch/index.js @@ -105,6 +105,10 @@ )) .then((response) => { // 4xx? 5xx? that's a paddlin' + // NOTICE: normally 4xx errors are returned to the client by other plugins, + // NOTICE: but here we are relying on multiple alternative endpoints; + // NOTICE: so, we want to maximize the chance that we get *something* useful + // TODO: shouldn't this reject() instead if (response.status >= 400) { // throw an Error to fall back to other plugins: throw new Error('HTTP Error: ' + response.status + ' ' + response.statusText); diff --git a/plugins/any-of/__tests__/browser.test.js b/plugins/any-of/__tests__/browser.test.js index 1a9bb8d..af0665a 100644 --- a/plugins/any-of/__tests__/browser.test.js +++ b/plugins/any-of/__tests__/browser.test.js @@ -122,32 +122,4 @@ describe('browser: any-of plugin', async () => { }) assertEquals(await response.json(), {test: "success"}) }); - - it("should throw an error when HTTP status is >= 400", async () => { - - window.fetch = spy((url, init) => { - return Promise.resolve( - new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found" - } - ) - ) - }); - - assertRejects( - async () => { - return await LibResilientPluginConstructors - .get('any-of')(LR, init) - .fetch('https://resilient.is/test.json') }, - AggregateError, - 'All promises were rejected' - ) - assertSpyCalls(fetch, 1); - }); }) diff --git a/plugins/delay/__tests__/browser.test.js b/plugins/delay/__tests__/browser.test.js index 612290f..df92c55 100644 --- a/plugins/delay/__tests__/browser.test.js +++ b/plugins/delay/__tests__/browser.test.js @@ -99,28 +99,4 @@ describe('browser: fetch plugin', async () => { assertEquals(response.headers.get('X-LibResilient-ETag'), 'TestingETagHeader') }); - it("should throw an error when HTTP status is >= 400", async () => { - window.fetch = (url, init) => { - const response = new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found", - url: url - }); - return Promise.resolve(response); - } - assertRejects( - async () => { - return await LibResilientPluginConstructors - .get('fetch')(LR) - .fetch('https://resilient.is/test.json') }, - Error, - 'HTTP Error: 404 Not Found' - ) - }); - }) diff --git a/plugins/delay/index.js b/plugins/delay/index.js index aa36553..c5632d6 100644 --- a/plugins/delay/index.js +++ b/plugins/delay/index.js @@ -35,6 +35,11 @@ // merge the defaults with settings from init let config = {...defaultConfig, ...init} + // reality check: if no wrapped plugin configured, or more than one, complain + if (config.uses.length != 1) { + throw new Error(`Expected exactly one plugin to wrap, but ${config.uses.length} configured.`) + } + /** * getting content using regular HTTP(S) fetch() */ diff --git a/plugins/dnslink-fetch/__tests__/browser.test.js b/plugins/dnslink-fetch/__tests__/browser.test.js index ae7b26e..5fad586 100644 --- a/plugins/dnslink-fetch/__tests__/browser.test.js +++ b/plugins/dnslink-fetch/__tests__/browser.test.js @@ -1950,58 +1950,4 @@ describe('browser: dnslink-fetch plugin', async () => { assertEquals(response.headers.get('X-LibResilient-Method'), 'dnslink-fetch') assertEquals(response.headers.get('X-LibResilient-Etag'), 'TestingLastModifiedHeader') }); - - it("should throw an error when HTTP status is >= 400", async () => { - - window.resolvingFetch = (url, init) => { - if (url.startsWith('https://dns.hostux.net/dns-query')) { - const response = new Response( - new Blob( - [JSON.stringify(fetchResponse[0])], - {type: fetchResponse[1]} - ), - { - status: 200, - statusText: "OK", - headers: { - 'Last-Modified': 'TestingLastModifiedHeader' - }, - url: url - }); - return Promise.resolve(response); - } else { - const response = new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found", - url: url - }); - return Promise.resolve(response); - } - } - window.fetch = spy(window.resolvingFetch) - - window.fetchResponse = [ - {Status: 0, Answer: [ - {type: 16, data: 'dnslink=/https/example.org'}, - {type: 16, data: 'dnslink=/http/example.net/some/path'} - ]}, - "application/json" - ] - - assertRejects( - async ()=>{ - const response = await LibResilientPluginConstructors - .get('dnslink-fetch')(LR, init) - .fetch('https://resilient.is/test.json') - console.log(response) - }, - Error, - 'HTTP Error:' - ) - }); }) diff --git a/plugins/dnslink-fetch/index.js b/plugins/dnslink-fetch/index.js index 80e43c0..53d7846 100644 --- a/plugins/dnslink-fetch/index.js +++ b/plugins/dnslink-fetch/index.js @@ -226,11 +226,7 @@ u=>fetch(u, init) )) .then((response) => { - // 4xx? 5xx? that's a paddlin' - if (response.status >= 400) { - // throw an Error to fall back to other plugins: - throw new Error('HTTP Error: ' + response.status + ' ' + response.statusText); - } + // all good, it seems LR.log(pluginName, "fetched:", response.url); diff --git a/plugins/error/index.js b/plugins/error/index.js index 74fe393..6b62055 100644 --- a/plugins/error/index.js +++ b/plugins/error/index.js @@ -23,6 +23,8 @@ type: "http", // only valid if type: http code: 500, + // valid only if type: http + headers: {}, // valid either way message: "Internal server error" } @@ -43,14 +45,17 @@ LR.log(pluginName, `erroring out for: ${url} — HTTP error`) // I guess we want a HTTP error then - var responseInit = { status: config.code, statusText: config.message, - headers: {}, + headers: config.headers, url: url }; - responseInit.headers['Content-Type'] = "text/plain" + + // we need some content type here + if (responseInit.headers['Content-Type'] === undefined) { + responseInit.headers['Content-Type'] = "text/plain" + } let blob = new Blob( [config.message], diff --git a/plugins/fetch/__tests__/browser.test.js b/plugins/fetch/__tests__/browser.test.js index 612290f..df92c55 100644 --- a/plugins/fetch/__tests__/browser.test.js +++ b/plugins/fetch/__tests__/browser.test.js @@ -99,28 +99,4 @@ describe('browser: fetch plugin', async () => { assertEquals(response.headers.get('X-LibResilient-ETag'), 'TestingETagHeader') }); - it("should throw an error when HTTP status is >= 400", async () => { - window.fetch = (url, init) => { - const response = new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found", - url: url - }); - return Promise.resolve(response); - } - assertRejects( - async () => { - return await LibResilientPluginConstructors - .get('fetch')(LR) - .fetch('https://resilient.is/test.json') }, - Error, - 'HTTP Error: 404 Not Found' - ) - }); - }) diff --git a/plugins/fetch/index.js b/plugins/fetch/index.js index d7fe153..5c1fa99 100644 --- a/plugins/fetch/index.js +++ b/plugins/fetch/index.js @@ -32,13 +32,12 @@ // run built-in regular fetch() return fetch(url, init) .then((response) => { - // 4xx? 5xx? that's a paddlin' - if (response.status >= 400) { - // throw an Error to fall back to LibResilient: - throw new Error('HTTP Error: ' + response.status + ' ' + response.statusText); - } - // all good, it seems - LR.log(pluginName, `fetched successfully: ${response.url}`); + + // we got something, it seems + // it might be a 2xx; it might be a 3xx redirect + // it might also be a 4xx or a 5xx error + // the service worker will know how to deal with those + LR.log(pluginName, `fetched:\n+-- url: ${response.url}\n+-- http status: ${response.status} (${response.statusText})`); // we need to create a new Response object // with all the headers added explicitly, diff --git a/plugins/test-plugin/__tests__/browser.test.js b/plugins/test-plugin/__tests__/browser.test.js index 612290f..df92c55 100644 --- a/plugins/test-plugin/__tests__/browser.test.js +++ b/plugins/test-plugin/__tests__/browser.test.js @@ -99,28 +99,4 @@ describe('browser: fetch plugin', async () => { assertEquals(response.headers.get('X-LibResilient-ETag'), 'TestingETagHeader') }); - it("should throw an error when HTTP status is >= 400", async () => { - window.fetch = (url, init) => { - const response = new Response( - new Blob( - ["Not Found"], - {type: "text/plain"} - ), - { - status: 404, - statusText: "Not Found", - url: url - }); - return Promise.resolve(response); - } - assertRejects( - async () => { - return await LibResilientPluginConstructors - .get('fetch')(LR) - .fetch('https://resilient.is/test.json') }, - Error, - 'HTTP Error: 404 Not Found' - ) - }); - }) diff --git a/plugins/test-plugin/index.js b/plugins/test-plugin/index.js index 521c0e5..fd82648 100644 --- a/plugins/test-plugin/index.js +++ b/plugins/test-plugin/index.js @@ -32,12 +32,11 @@ // run built-in regular fetch() return fetch(url, init) .then(async (response) => { - // 4xx? 5xx? that's a paddlin' - if (response.status >= 400) { - // throw an Error to fall back to LibResilient: - throw new Error('HTTP Error: ' + response.status + ' ' + response.statusText); - } - // all good, it seems + + // we got something, it seems + // it might be a 2xx; it might be a 3xx redirect + // it might also be a 4xx or a 5xx error + // the service worker will know how to deal with those LR.log(pluginName, `fetched successfully: ${response.url}`); // we need to create a new Response object diff --git a/service-worker.js b/service-worker.js index c7f010f..4b49c7e 100644 --- a/service-worker.js +++ b/service-worker.js @@ -274,12 +274,12 @@ let cacheConfigJSON = async (configURL, cresponse, use_source) => { * cresponse - the Response object to work with */ let getConfigJSON = async (cresponse) => { - if (cresponse == undefined) { - self.log('service-worker', 'config.json response is undefined') + if ( (cresponse === undefined) || (cresponse === null) || (typeof cresponse !== 'object') || ! ("status" in cresponse) || ! ("statusText" in cresponse) ) { + self.log('service-worker', 'config.json response is undefined or invalid') return false; } if (cresponse.status != 200) { - self.log('service-worker', `config.json response status is not 200: ${cdata.status} ${cdata.statusText})`) + self.log('service-worker', `config.json response status is not 200: ${cresponse.status} ${cresponse.statusText})`) return false; } // cloning the response before applying json() @@ -687,6 +687,9 @@ let initServiceWorker = async () => { cacheConfigJSON(configURL, cresponse, "v1") } }) + .catch((e)=>{ + self.log('service-worker', `stale config.json fetch failed.`) + }) } } catch(e) { @@ -820,6 +823,8 @@ let LibResilientClient = class { constructor(clientId) { + self.log('service-worker', `new client: ${clientId}`) + // we often get the clientId long before // we are able to get a valid client out of it // @@ -881,12 +886,13 @@ let LibResilientResourceInfo = class { this.values = { url: '', // read only after initialization clientId: null, // the client on whose behalf that request is being processed - lastError: null, // error from the previous plugin (for state:running) or the last emitted error (for state:failed or state:success) method: null, // name of the current plugin (in case of state:running) or last plugin (for state:failed or state:success) state: null, // can be "failed", "success", "running" serviceWorker: 'COMMIT_UNKNOWN' // this will be replaced by commit sha in CI/CD; read-only } - this.client = null; + + // errors from the plugins, contains tuples: [plugin-name, exception-or-response-object] + this.errors = [] // queued messages for when we have a client available this.messageQueue = [] @@ -914,11 +920,11 @@ let LibResilientResourceInfo = class { 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 + // update simple read-write properties Object .keys(data) .filter((k)=>{ - return ['lastError', 'method', 'state'].includes(k) + return ['method', 'state'].includes(k) }) .forEach((k)=>{ msg += '\n+-- ' + k + ': ' + data[k] @@ -928,23 +934,31 @@ let LibResilientResourceInfo = class { } this.values[k] = data[k] }) + // start preparing the data to postMessage() over to the client + let msgdata = {...this.values} + // handle any error related info + if ('error' in data) { + // push the error info, along with method that generated it, onto the error stack + this.errors.push([this.values.method, data.error]) + // response? + if ( (typeof data.error === 'object') && ("statusText" in data.error) ) { + msgdata.error = `HTTP status: ${data.error.status} ${data.error.statusText}` + // nope, exception + } else { + msgdata.error = data.error.toString() + } + changed = true + } self.log('service-worker', msg) // send the message to the client if (changed) { postMessage( this.values.clientId, - {...this.values} + msgdata ); } } - /** - * lastError property - */ - get lastError() { - return this.values.lastError - } - /** * method property */ @@ -1015,48 +1029,75 @@ let initFromRequest = (req) => { * reqInfo - instance of LibResilientResourceInfo */ let libresilientFetch = (plugin, url, init, reqInfo) => { - // status of the plugin - reqInfo.update({ - method: (plugin && "name" in plugin) ? plugin.name : "unknown", - state: "running" - }) - // log stuff - self.log('service-worker', "LibResilient Service Worker handling URL:", url, - '\n+-- init:', Object.getOwnPropertyNames(init).map(p=>`\n - ${p}: ${init[p]}`).join(''), - '\n+-- using method(s):', plugin.name + // we really need to catch any exceptions here + // otherwise other plugins will not run! + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Promise/catch#gotchas_when_throwing_errors + try { + + // do we actually have a plugin to work with? + if ( (! plugin) || (typeof plugin !== "object") || ! ("name" in plugin) || ! ("fetch" in plugin) ) { + return Promise.reject( + new Error("Plugin is not valid.") ) - - // starting the fetch... - // if it errors out immediately, at least we don't have to deal - // with a dangling promise timeout, set up below - let fetch_promise = plugin.fetch(url, init) - - let timeout_promise, timeout_id - [timeout_promise, timeout_id] = promiseTimeout( - self.LibResilientConfig.defaultPluginTimeout, - false, - `LibResilient request using ${plugin.name} timed out after ${self.LibResilientConfig.defaultPluginTimeout}ms.` - ) - - // making sure there are no dangling promises etc - // - // this has to happen asynchronously - fetch_promise - // make sure the timeout is cancelled as soon as the promise resolves - // we do not want any dangling promises/timeouts after all! - .finally(()=>{ - clearTimeout(timeout_id) + } + + // status of the plugin + reqInfo.update({ + method: (plugin && "name" in plugin) ? plugin.name : "unknown", + state: "running" }) - // no-op to make sure we don't end up with dangling rejected premises - .catch((e)=>{}) - - // race the plugin(s) vs. the timeout - return Promise - .race([ - fetch_promise, - timeout_promise - ]); + + // log stuff + self.log('service-worker', "LibResilient Service Worker handling URL:", url, + '\n+-- init:', Object.getOwnPropertyNames(init).map(p=>`\n - ${p}: ${init[p]}`).join(''), + '\n+-- using method(s):', plugin.name + ) + + // starting the fetch... + // if it errors out immediately, at least we don't have to deal + // with a dangling promise timeout, set up below + let fetch_promise = plugin + .fetch(url, init) + .then((response)=>{ + // 5xx? that's a paddlin' + // we do want to pass 3xx and 4xx on back to the client though! + if (response.status >= 500) { + // throw an Error to fall back to LibResilient: + throw new Error('HTTP Error: ' + response.status + ' ' + response.statusText); + } + // ok, we're good + return response + }) + + let timeout_promise, timeout_id + [timeout_promise, timeout_id] = promiseTimeout( + self.LibResilientConfig.defaultPluginTimeout, + false, + `LibResilient request using ${plugin.name} timed out after ${self.LibResilientConfig.defaultPluginTimeout}ms.` + ) + + // making sure there are no dangling promises etc + // + // this has to happen asynchronously + fetch_promise + // make sure the timeout is cancelled as soon as the promise resolves + // we do not want any dangling promises/timeouts after all! + .finally(()=>{ + clearTimeout(timeout_id) + }) + // no-op to make sure we don't end up with dangling rejected premises + .catch((e)=>{}) + + // race the plugin(s) vs. the timeout + return Promise + .race([ + fetch_promise, + timeout_promise + ]); + } catch(e) { + return Promise.reject(e) + } } @@ -1128,7 +1169,7 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt '\n+-- error : ' + error.toString()) // save info in reqInfo -- status of the previous method reqInfo.update({ - lastError: error.toString() + error: error }) return libresilientFetch(currentPlugin, url, init, reqInfo) }) @@ -1145,7 +1186,6 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt // record the success reqInfo.update({ - lastError: null, state:"success" }) @@ -1155,7 +1195,7 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt // 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); + 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 @@ -1165,7 +1205,7 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt try { getResourceThroughLibResilient(url, init, clientId, false, true, response.clone()) .catch((e)=>{ - self.log('service-worker', 'background no-stashed fetch failed for:', url); + self.log('service-worker', 'background no-stashed fetch failed for:', url, `\n+-- error: ${e}`); }) } catch(e) { self.log('service-worker', 'background no-stashed fetch failed for:', url, `\n+-- error: ${e}`); @@ -1239,17 +1279,29 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt }) // a final catch... in case all plugins fail .catch((err)=>{ - self.log('service-worker', "LibResilient failed completely: ", err, - '\n+-- URL : ' + url) + self.log('service-worker', `all plugins failed for:\n+-- url : ${url}`) // cleanup reqInfo.update({ state: "failed", - lastError: err.toString() + error: err }) + decrementActiveFetches(clientId) - // rethrow - throw err + + // print out all the errors from plugins in console for debugging purposes + self.log('service-worker', `request errored out:\n+-- url: ${reqInfo.url}\n+-- plugin errors:\n${ + reqInfo + .errors + .reduce((acc, cur)=>{ + if ( (typeof cur[0] !== "string") || (cur[0].length === 0) ) { + cur[0] = '' + } + return acc + ' ' + cur.join(': ') + '\n' + }, '')}`) + + // return the error wrapped in a rejected promise + return Promise.reject(err) }) } @@ -1271,8 +1323,170 @@ self.addEventListener('activate', async event => { self.log('service-worker', "activated LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); }); +/* + * messages to be used on the still-loading screen and on the error page + * + * TODO: make this configurable via config.json + * TODO: https://gitlab.com/rysiekpl/libresilient/-/issues/82 + */ +let still_loading_messages = { + title: "Still loading", + body: "The resource is still being loaded, thank you for your patience.

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

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

+

attempts: 1

+

${init_msgs.body}

+

+ + ` + + // return what we got out of that + return html +} + self.addEventListener('fetch', async event => { return void event.respondWith(async function () { + // initialize the SW; this is necessary as SW can be stopped at any time // and restarted when an event gets triggered -- `fetch` is just such an event. // @@ -1282,6 +1496,7 @@ self.addEventListener('fetch', async event => { // // the good news is that the config.json should have been cached already await initServiceWorker() + // if event.resultingClientId is available, we need to use this // otherwise event.clientId is what we want // ref. https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/resultingClientId @@ -1348,156 +1563,121 @@ self.addEventListener('fetch', async event => { // get handled by plugins in case of an error let lrPromise = getResourceThroughLibResilient(url, init, clientId) - // is the stillLoadingScreen enabled, and are we navigating, or just fetching some resource? - if ( ( self.LibResilientConfig.stillLoadingTimeout > 0 ) && ( event.request.mode === 'navigate' ) ) { + // are we navigating, or just fetching some resource? + if ( event.request.mode === 'navigate' ) { - self.log('service-worker', `handling a navigate request; still-loading timeout: ${self.LibResilientConfig.stillLoadingTimeout}.`) + // this is the promise we will want to return in the end + let finalPromise = null - let slPromise, slTimeoutId - [slPromise, slTimeoutId] = promiseTimeout(self.LibResilientConfig.stillLoadingTimeout, true) + // navigating! is the still-loading screen enabled? + if ( self.LibResilientConfig.stillLoadingTimeout > 0 ) { - // make sure to clear the timeout related to slPromise - // in case we manage to get the content through the plugins - lrPromise - .then(()=>{ - self.log('service-worker', `content retrieved; still-loading timeout cleared.`) - clearTimeout(slTimeoutId) - }) - - // return a Promise that races the "still loading" screen promise against the LibResilient plugins - return Promise.race([ - // regular fetch-through-plugins - lrPromise, - - // the "still loading screen" - // - // this will delay a specified time, and ten return a Response - // with very basic HTML informing the user that the page is still loading, - // a Refresh header set, and a link for the user to reload the screen manually - slPromise - .then(()=>{ - - // inform - self.log('service-worker', 'handling a navigate request is taking too long, showing the still-loading screen') - - // we need to create a new Response object - // with all the headers added explicitly, - // since response.headers is immutable - var responseInit = { - status: 202, - statusText: "Accepted", - headers: {}, - url: url - }; - responseInit.headers['Content-Type'] = "text/html" - // refresh: we want a minimum of 1s; stillLoadingTimeout is in ms! - //responseInit.headers['Refresh'] = Math.ceil( self.LibResilientConfig.stillLoadingTimeout / 1000 ) - //responseInit.headers['ETag'] = ??? - //responseInit.headers['X-LibResilient-ETag'] = ??? - responseInit.headers['X-LibResilient-Method'] = "still-loading" - - // TODO: make this configurable via config.json - // TODO: https://gitlab.com/rysiekpl/libresilient/-/issues/82 - let stillLoadingHTML = `Still loading... - -

Still loading

-

attempts: 1

-

The content is still being loaded, thank you for your patience.

This page will auto-reload in a few seconds. If it does not, please click here.

- ` - - let blob = new Blob( - [stillLoadingHTML], - {type: "text/html"} - ) - - return new Response( - blob, - responseInit - ) - }) - ]) + ]) + + // okay, no still-loading screen, but this is still a navigate request, so we want to display something to the user + } else { + self.log('service-worker', `handling a navigate request, but still-loading screen is disabled.`) + finalPromise = lrPromise + } + + // return finalPromise, with a catch! + return finalPromise.catch((e)=>{ + // inform + self.log('service-worker', 'handling a failed navigate request, showing the user-facing error screen') + + // we need to create a new Response object + // with all the headers added explicitly, + // since response.headers is immutable + var responseInit = { + status: 404, + statusText: "Not Found", + headers: {}, + url: url + }; + responseInit.headers['Content-Type'] = "text/html" + responseInit.headers['X-LibResilient-Method'] = "failed" + + // get the still-loading page contents + // it only needs to display the failure messages + let stillLoadingHTML = getUserFacingHTML( + failure_messages + ) + + let blob = new Blob( + [stillLoadingHTML], + {type: "text/html"} + ) + + return new Response( + blob, + responseInit + ) + }) // nope, just fetching a resource } else { - if ( event.request.mode === 'navigate' ) { - self.log('service-worker', `handling a navigate request, but still-loading screen is disabled.`) - } else { - self.log('service-worker', 'handling a regular request; still-loading screen will not be used.') - } - // no need for the whole "still loading screen" flow - return lrPromise; + // no need for a still-loading screen, no need for an user-facing error screen if the request fails + self.log('service-worker', 'handling a regular request; still-loading screen will not be used.') + return lrPromise } }()) });