diff --git a/__tests__/config.json b/__tests__/config.json index 93b668b..72e0bb6 100644 --- a/__tests__/config.json +++ b/__tests__/config.json @@ -1,7 +1,17 @@ { + "defaultPluginTimeout": 4500, + "stillLoadingTimeout": 1, "plugins": [{ - "name": "test-plugin" + "name": "cache" + },{ + "name": "delay", + "delay": [ + ["test.html$", 7000] + ], + "defaultDelay": 5000, + "uses": [{ + "name": "fetch" + }] }], - "useMimeSniffingLibrary": true, - "loggedComponents": ["service-worker", "test-plugin"] + "loggedComponents": ["service-worker", "fetch", "delay"] } diff --git a/__tests__/service-worker/service-worker.test.js b/__tests__/service-worker/service-worker.test.js index a347e2c..93b1f49 100644 --- a/__tests__/service-worker/service-worker.test.js +++ b/__tests__/service-worker/service-worker.test.js @@ -10,7 +10,8 @@ import { assert, assertThrows, assertRejects, - assertEquals + assertEquals, + assertNotEquals } from "https://deno.land/std@0.183.0/testing/asserts.ts"; import { @@ -32,10 +33,15 @@ class FetchEvent extends Event { if (request.indexOf('http') != 0) { request = window.location.origin + request } - if (init == null) { + if (init === null) { request = new Request(request) } else { request = new Request(request, init) + // for some reason setting the mode in the init object just doesn't work + // we need to go manual + if ('mode' in init) { + request.mode = init.mode + } } } this.request = request @@ -69,7 +75,7 @@ beforeAll(async ()=>{ } } - // get a Promise resolvint to a mocked Response object built based on supplied data + // get a Promise resolving to a mocked Response object built based on supplied data window.getMockedResponse = (url, init, response_data={}) => { let rdata = { ...responseMockedData, @@ -226,11 +232,18 @@ beforeAll(async ()=>{ postMessage: window.clients.prototypePostMessage } }, + claim: 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 } + window.skipWaiting = async () => { + return undefined; + } + // we need to be able to reliably wait for SW installation // which is triggered by an "install" Event window.sw_install_ran = false @@ -445,6 +458,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -478,6 +492,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -497,6 +512,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -517,6 +533,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -554,6 +571,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -574,6 +592,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -594,6 +613,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -614,6 +634,28 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) + 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 'stillLoadingTimeout' field in config.json contains an invalid value", async () => { + + let mock_response_data = { + data: JSON.stringify({loggedComponents: ['service-worker', 'fetch'], plugins: [{name: "fetch"}], defaultPluginTimeout: 5000, stillLoadingTimeout: 'not an integer'}) + } + window.fetch = spy(window.getMockedFetch(mock_response_data)) + + 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.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -634,6 +676,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -654,6 +697,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 10000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 5000) assertEquals(self.LibResilientConfig.plugins, [{name: "fetch"},{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'fetch', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, true) @@ -663,7 +707,7 @@ describe('service-worker', 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}) + data: JSON.stringify({loggedComponents: ['service-worker', 'cache'], plugins: [{name: "cache"}], defaultPluginTimeout: 5000, stillLoadingTimeout: 1000, normalizeQueryParams: false, useMimeSniffingLibrary: true}) } window.fetch = spy(window.getMockedFetch(mock_response_data)) @@ -673,6 +717,7 @@ describe('service-worker', async () => { assertEquals(typeof self.LibResilientConfig, "object") assertEquals(self.LibResilientConfig.defaultPluginTimeout, 5000) + assertEquals(self.LibResilientConfig.stillLoadingTimeout, 1000) assertEquals(self.LibResilientConfig.plugins, [{name: "cache"}]) assertEquals(self.LibResilientConfig.loggedComponents, ['service-worker', 'cache']) assertEquals(self.LibResilientConfig.normalizeQueryParams, false) @@ -1906,7 +1951,7 @@ describe('service-worker', async () => { assertSpyCalls(resolvingFetch2, 1) assertSpyCall( window.clients.prototypePostMessage, - 6, + 8, { args: [{ url: "https://test.resilient.is/test.json", fetchedDiffers: true @@ -2614,4 +2659,227 @@ describe('service-worker', async () => { assertEquals(await self.guessMimeType("no-such-extension", "test arg 2"), "") assertSpyCalls(window.fileType.fileTypeFromBuffer, 1) }) + + // ======================================================================== + // ======================================================================== + // ======================================================================== + + it("should use the still-loading screen when handling a navigation request with a stashing plugin configured and enabled and stillLoadingTimeout set to a positive integer", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'delayed-resolve' + },{ + name: "mock-stash" + }], + defaultPluginTimeout: 100, + stillLoadingTimeout: 1, + loggedComponents: ['service-worker'] + } + window.LibResilientPluginConstructors.set('delayed-resolve', ()=>{ + return { + name: 'delayed-resolve', + description: 'Resolve all requests with a delay.', + version: '0.0.1', + fetch: async (url, init) => { + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + return fetch(url, init) + } + } + }) + window.LibResilientPluginConstructors.set('mock-stash', ()=>{ + return { + name: 'mock-stash', + description: 'No-op mock stashing plugin.', + version: '0.0.1', + fetch: a=>Promise.resolve(a), + stash: a=>Promise.resolve(a) + } + }) + + 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() + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + + assertEquals((await response.text()).slice(0, 58), '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 () => { + self.LibResilientConfig = { + plugins: [{ + name: 'delayed-resolve' + },{ + name: "mock-stash" + }], + defaultPluginTimeout: 100, + stillLoadingTimeout: 1, + loggedComponents: ['service-worker'] + } + window.LibResilientPluginConstructors.set('delayed-resolve', ()=>{ + return { + name: 'delayed-resolve', + description: 'Resolve all requests with a delay.', + version: '0.0.1', + fetch: async (url, init) => { + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + return fetch(url, init) + } + } + }) + window.LibResilientPluginConstructors.set('mock-stash', ()=>{ + return { + name: 'mock-stash', + description: 'No-op mock stashing plugin.', + version: '0.0.1', + fetch: a=>Promise.resolve(a), + stash: a=>Promise.resolve(a) + } + }) + + 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) + let response = await fetch_event.waitForResponse() + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + + assertEquals(await response.json(), { test: "success" }) + }) + + it("should not use the still-loading screen when handling a navigation request, even if a stashing plugin is configured and enabled, but stillLoadingTimeout is not set to a positive integer", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'delayed-resolve' + },{ + name: "mock-stash" + }], + defaultPluginTimeout: 100, + stillLoadingTimeout: 0, + loggedComponents: ['service-worker'] + } + window.LibResilientPluginConstructors.set('delayed-resolve', ()=>{ + return { + name: 'delayed-resolve', + description: 'Resolve all requests with a delay.', + version: '0.0.1', + fetch: async (url, init) => { + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + return fetch(url, init) + } + } + }) + window.LibResilientPluginConstructors.set('mock-stash', ()=>{ + return { + name: 'mock-stash', + description: 'No-op mock stashing plugin.', + version: '0.0.1', + fetch: a=>Promise.resolve(a), + stash: a=>Promise.resolve(a) + } + }) + + 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() + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + + assertEquals(await response.json(), { test: "success" }) + }) + + it("should not use the still-loading screen when handling a navigation request when a stashing plugin is not configured, even though stillLoadingTimeout is set to a positive integer", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'delayed-resolve' + }], + defaultPluginTimeout: 100, + stillLoadingTimeout: 1, + loggedComponents: ['service-worker'] + } + window.LibResilientPluginConstructors.set('delayed-resolve', ()=>{ + return { + name: 'delayed-resolve', + description: 'Resolve all requests with a delay.', + version: '0.0.1', + fetch: async (url, init) => { + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + return fetch(url, init) + } + } + }) + window.LibResilientPluginConstructors.set('mock-stash', ()=>{ + return { + name: 'mock-stash', + description: 'No-op mock stashing plugin.', + version: '0.0.1', + fetch: a=>Promise.resolve(a), + stash: a=>Promise.resolve(a) + } + }) + + 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() + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + + assertEquals(await response.json(), { test: "success" }) + }) + + it("should not use the still-loading screen when handling a navigation request when a stashing plugin is configured but not enabled, even though stillLoadingTimeout is set to a positive integer", async () => { + self.LibResilientConfig = { + plugins: [{ + name: 'delayed-resolve' + },{ + name: "mock-stash", + enabled: false + }], + defaultPluginTimeout: 100, + stillLoadingTimeout: 1, + loggedComponents: ['service-worker'] + } + window.LibResilientPluginConstructors.set('delayed-resolve', ()=>{ + return { + name: 'delayed-resolve', + description: 'Resolve all requests with a delay.', + version: '0.0.1', + fetch: async (url, init) => { + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + return fetch(url, init) + } + } + }) + window.LibResilientPluginConstructors.set('mock-stash', ()=>{ + return { + name: 'mock-stash', + description: 'No-op mock stashing plugin.', + version: '0.0.1', + fetch: a=>Promise.resolve(a), + stash: a=>Promise.resolve(a) + } + }) + + 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() + await new Promise(resolve => setTimeout(resolve, (self.LibResilientConfig.stillLoadingTimeout + 50))) + + assertEquals(await response.json(), { test: "success" }) + }) }) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 4ba8d68..253de8f 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -155,10 +155,10 @@ LibResilient information is kept per-request in the Service Worker, meaning it i The data provided (per each requested URL handled by the Service Worker) is: - `clientId` – the [Client ID](https://developer.mozilla.org/en-US/docs/Web/API/FetchEvent/clientId) for the request (that is, the Client ID of this browser window) - `url` – the URL of the request - - `Service Worker` – the commit SHA of the Service Worker that handled the request - - `fetchError` – `null` if the request completed successfully via regular HTTPS; otherwise the error message - - `method` – the method by which the request was completed: "`fetch`" is regular HTTPS `fetch()`, `gun-ipfs` means Gun and IPFS were used, etc. - - `state` – the state of the request (`running`, `error`, `success`) + - `serviceWorker` – the commit SHA of the Service Worker that handled the request + - `lastError` – the last error message emitted from any plugin + - `method` – the name of the plugin by which the request was completed + - `state` – the state of the request (`running`, `failed`, `success`) The code in the browser window context is responsible for keeping a more permanent record of the URLs requested, the methods used, and the status of each, if needed. @@ -195,3 +195,27 @@ There are two levels of cache of the `config.json` file employed here: "*regular Whenever a configuration file is successfully loaded and applied, it gets saved to the "*verified*" cache, so that it is available as a known-good fall-back in the future. After the `config.json` file is loaded and applied, if it was loaded from any of the caches it is checked for staleness. If it is stale (older than 24h), a an attempt is made to retrieve a newer version through the currently configured plugins. If it succeeds and the retrieved `config.json` passes verification, it is cached in the "*regular*" cache, to be used next time the service worker i initialized. This verification involves checking the syntax of the file and if it contains all the necessary fields. If the file was retrieved using means other than regular `fetch()`, it is *also* checked in case it requires any plugins whose code has not been loaded in the currently deployed service worker. If it does, it is discarded — the Service Workers API specifies that code loaded by the service worker can *only* come from the original domain; if the config file was loaded using some other means, it might not be possible to load the necessary plugin code when initializing the service worker later. + +## Still-loading screen + +Depending on the plugin configuration, some requests can take a long time -- even tens of seconds. This is especially true for plugins using non-standard transports, like IPFS. + +When requests that take that long, user experience suffers. Visitors have no clue if something went wrong and the request is just hanging there, or if they should just wait a bit longer. Browsers will at some point time out the request and just display a generic error screen. + +To improve this user experience, the LibResilient service worker implements a "still-loading" screen for navigate requests. Navigate requests are requests that the browser understands as meaning to navigate between two different pages (in code this means that the [`Request` object has `mode` property set to `navigate`](https://developer.mozilla.org/en-US/docs/Web/API/Request/mode)). + +Navigate requests are meant to return a resource that is directly displayed to the visitor. So, the still-loading screen will not be returned in case of `fetch()` requests for some HTML parts to inject in the page, or requests for style sheets, images, scripts, etc., that are to be used in an already displayed page. But it will be displayed when a visitor navigates to a resource to display it in their browser window and the request is taking too long -- even if that resource is a style sheet, script, or image. + +If a navigate request takes too long (longer than `stillLoadingTimeout` configuration setting, to be precise, which by default is set to 5000ms), and there is a stashing plugin configured and enabled, the service worker will return a hard-coded "Still loading..." HTML page, with a simple throbber and attempts counter to indicate things are still happening in the background. It also contains a short explainer text and a link for the user to click if they think the request is taking too long. + +That still-loading screen is listening to the messages from the service worker (sent using the [`Client.postMessage()`](https://developer.mozilla.org/en-US/docs/Web/API/Client/postMessage) API call). When the service worker indicates that content is ready, the page will automatically reload to display it. If instead the service worker indicates a final failure of the request, the text on the still-loading screen is modified to reflect that. + +The still-loading screen is *only* displayed when *all* of these conditions are met: +1. the `stillLoadingTimeout` is set to number greater than zero; +1. there is at least one stashing plugin (normally, `cache`) configured and enabled; +1. the request in question is a navigation request; +1. the request is taking longer than `stillLoadingTimeout`. + +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. diff --git a/plugins/delay/README.md b/plugins/delay/README.md new file mode 100644 index 0000000..3c9eab4 --- /dev/null +++ b/plugins/delay/README.md @@ -0,0 +1,12 @@ +# Plugin: `delay` + +- **status**: beta +- **type**: wrapping + +This plugin wraps a plugin, and delays returning the response from it by configurable amount. + +## Configuration: + +TBD + + diff --git a/plugins/delay/__tests__/browser.test.js b/plugins/delay/__tests__/browser.test.js new file mode 100644 index 0000000..612290f --- /dev/null +++ b/plugins/delay/__tests__/browser.test.js @@ -0,0 +1,126 @@ +import { + describe, + it, + afterEach, + beforeEach +} from "https://deno.land/std@0.183.0/testing/bdd.ts"; + +import { + assert, + assertRejects, + assertEquals +} from "https://deno.land/std@0.183.0/testing/asserts.ts"; + +import { + assertSpyCall, + assertSpyCalls, + spy, +} from "https://deno.land/std@0.183.0/testing/mock.ts"; + +beforeEach(()=>{ + window.fetch = spy(window.resolvingFetch) +}) + +afterEach(()=>{ + window.fetch = null +}) + +describe('browser: fetch plugin', async () => { + window.LibResilientPluginConstructors = new Map() + window.LR = { + log: (component, ...items)=>{ + console.debug(component + ' :: ', ...items) + } + } + window.resolvingFetch = (url, init) => { + return Promise.resolve( + new Response( + new Blob( + [JSON.stringify({ test: "success" })], + {type: "application/json"} + ), + { + status: 200, + statusText: "OK", + headers: { + 'ETag': 'TestingETagHeader' + } + } + ) + ) + } + window.fetch = null + await import("../../../plugins/fetch/index.js"); + + it("should register in LibResilientPluginConstructors", () => { + assertEquals(LibResilientPluginConstructors.get('fetch')(LR).name, 'fetch'); + }); + + it("should return data from fetch()", async () => { + const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json'); + + assertSpyCalls(fetch, 1); + assertEquals(await response.json(), {test: "success"}) + }); + + it("should pass the Request() init data to fetch()", async () => { + var initTest = { + method: "GET", + headers: new Headers({"x-stub": "STUB"}), + mode: "mode-stub", + credentials: "credentials-stub", + cache: "cache-stub", + referrer: "referrer-stub", + redirect: "redirect-stub", + integrity: "integrity-stub" + } + const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json', initTest); + assertSpyCall( + fetch, + 0, + { + args: [ + 'https://resilient.is/test.json', + initTest // TODO: does the initTest actually properly work here? + ] + }) + assertEquals(await response.json(), {test: "success"}) + }); + + it("should set the LibResilient headers", async () => { + const response = await LibResilientPluginConstructors.get('fetch')(LR).fetch('https://resilient.is/test.json'); + + assertSpyCalls(fetch, 1); + assertEquals(await response.json(), {test: "success"}) + + assertEquals(response.headers.has('X-LibResilient-Method'), true) + assertEquals(response.headers.get('X-LibResilient-Method'), 'fetch') + assertEquals(response.headers.has('X-LibResilient-Etag'), true) + 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 new file mode 100644 index 0000000..aa36553 --- /dev/null +++ b/plugins/delay/index.js @@ -0,0 +1,81 @@ +/* ========================================================================= *\ +|* === Delay plugin === *| +\* ========================================================================= */ + +/** + * this plugin does not implement any push method + */ + +// no polluting of the global namespace please +(function(LRPC){ + // this never changes + const pluginName = "delay" + LRPC.set(pluginName, (LR, init={})=>{ + + /* + * plugin config settings + */ + + // sane defaults + let defaultConfig = { + // array of two-element arrays + // ["/regex_match/", ] + // first match wins + delay: [], + + // default delay, in ms + defaultDelay: 1000, + + // plugin to wrap, regular fetch by default + uses: [{ + name: "fetch" + }] + } + + // merge the defaults with settings from init + let config = {...defaultConfig, ...init} + + /** + * getting content using regular HTTP(S) fetch() + */ + let fetchContent = (url, init={}) => { + LR.log(pluginName, `delayed retrieval: ${url}`) + // we really want to make fetch happen, Regina! + // TODO: this change should *probably* be handled on the Service Worker level + init.cache = 'reload' + + // establish the default delay + let impose_delay = config.defaultDelay + LR.log(pluginName, `default delay: ${impose_delay}`) + + // see if we have any specific delay rule that matches + // first match wins + for (sp of config.delay) { + LR.log(pluginName, `checking delay rule: ${sp}`) + let re = new RegExp(sp[0]) + if (url.search(re) > -1) { + LR.log(pluginName, `delay rule matched: ${sp[0]}, delay set to: ${sp[1]}`) + impose_delay = sp[1] + break; + } + } + + // wait a bit and run the first wrapped plugin's fetch() + return promiseTimeout(impose_delay, true)[0] + .then(() => { + return config.uses[0].fetch(url, init) + }) + } + + // return the plugin + return { + name: pluginName, + description: 'Configurable delay!', + version: 'COMMIT_UNKNOWN', + fetch: fetchContent, + uses: config.uses + } + + }) +// done with not polluting the global namespace +})(LibResilientPluginConstructors) diff --git a/plugins/error/README.md b/plugins/error/README.md new file mode 100644 index 0000000..d18c7f8 --- /dev/null +++ b/plugins/error/README.md @@ -0,0 +1,4 @@ +# Plugin: `error` + +- **status**: alpha +- **type**: debugging plugin, used during development and testing diff --git a/plugins/error/index.html b/plugins/error/index.html new file mode 100644 index 0000000..10ef7ab --- /dev/null +++ b/plugins/error/index.html @@ -0,0 +1,18 @@ + + + + + + + + +

error

+

This is a simple debugging harness for the error plugin of LibResilient.

+

The plugin should now have been initialized in the theplugin global variable. Open your browser's JavaScript console to start playing with it.

+ + diff --git a/plugins/error/index.js b/plugins/error/index.js new file mode 100644 index 0000000..74fe393 --- /dev/null +++ b/plugins/error/index.js @@ -0,0 +1,76 @@ +/* ========================================================================= *\ +|* === Error plugin === *| +\* ========================================================================= */ + +/** + * this plugin is just a debugging plugin to be used in testing configurations + * when an erroring-out plugin is needed + */ + +// no polluting of the global namespace please +(function(LRPC){ + // this never changes + const pluginName = "error" + LRPC.set(pluginName, (LR, init={})=>{ + + /* + * plugin config settings + */ + + // sane defaults + let defaultConfig = { + // type can be "exception" or "http" + type: "http", + // only valid if type: http + code: 500, + // valid either way + message: "Internal server error" + } + + // merge the defaults with settings from init + let config = {...defaultConfig, ...init} + /** + * getting content using regular HTTP(S) fetch() + */ + let errorOut = (url, init={}) => { + + // exception? + if (config.type !== "http") { + LR.log(pluginName, `erroring out for: ${url} — exception`) + throw new Error(config.message) + } + + 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: {}, + url: url + }; + responseInit.headers['Content-Type'] = "text/plain" + + let blob = new Blob( + [config.message], + {type: "text/plain"} + ) + // shouldn't this be a Promise though? + return Promise.resolve(new Response( + blob, + responseInit + )) + } + + // return the plugin + return { + name: pluginName, + description: 'Errors, errors everywhere', + version: 'COMMIT_UNKNOWN', + fetch: errorOut + } + + }) +// done with not polluting the global namespace +})(LibResilientPluginConstructors) diff --git a/service-worker.js b/service-worker.js index 4e1b923..c7f010f 100644 --- a/service-worker.js +++ b/service-worker.js @@ -12,6 +12,14 @@ if (typeof self.LibResilientConfig !== 'object' || self.LibResilientConfig === n // how long do we wait before we decide that a plugin is unresponsive, // and move on? defaultPluginTimeout: 10000, + + // how long should LibResilient wait before displaying the "still loading" screen + // to the user if the request mode is "navigate"? + // + // NOTICE: the still-loading screen is only used if this setting is > 0 + // NOTICE: *and* there is a stashing plugin (normally, `cache`) configured and enabled in the config + // NOTICE: this is done to avoid loops -- otherwise, user would find themselves in a (manual, but still) loop + stillLoadingTimeout: 5000, // plugins settings namespace // @@ -217,6 +225,13 @@ let verifyConfigData = (cdata) => { return false; } } + // stillLoadingTimeout is optional + if ("stillLoadingTimeout" in cdata) { + if (!Number.isInteger(cdata.stillLoadingTimeout)) { + self.log('service-worker', 'fetched config contains invalid "stillLoadingTimeout" data (integer expected)') + return false; + } + } // normalizeQueryParams is optional if ("normalizeQueryParams" in cdata) { if (cdata.normalizeQueryParams !== true && cdata.normalizeQueryParams !== false) { @@ -292,6 +307,10 @@ let executeConfig = (config) => { // this is the stash for plugins that need dependencies instantiated first let dependentPlugins = new Array() + // do we have any stashing plugins enabled? + // this is important for the still-loading screen + let stashingEnabled = false + // only now load the plugins (config.json could have changed the defaults) while (pluginsConfig.length > 0) { @@ -346,12 +365,16 @@ let executeConfig = (config) => { let plugin = LibResilientPluginConstructors.get(pluginConfig.name)(self, pluginConfig) self.log('service-worker', `${pluginConfig.name}: instantiated`) + // is this a stashing plugin? + // we need at least one stashing plugin to be able to use the still-loading screen + stashingEnabled = stashingEnabled || ( ( "stash" in plugin ) && ( typeof plugin.stash === "function" ) ) + // do we have a stashed plugin that requires dependencies? if (dependentPlugins.length === 0) { // no we don't; so, this plugin goes directly to the plugin list self.LibResilientPlugins.push(plugin) // we're done here - self.log('service-worker', `${pluginConfig.name}: no dependent plugins, pushing directly to LibResilientPlugins`) + self.log('service-worker', `${pluginConfig.name}: no dependent plugins left, pushing directly to LibResilientPlugins`) break; } @@ -385,7 +408,7 @@ let executeConfig = (config) => { } - // finally -- do we want to use MIME type guessing based on content? + // 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) { @@ -405,6 +428,13 @@ let executeConfig = (config) => { } } + // finally -- if we do not have *any* stashing plugins enabled, + // we need to disable the still-loading screen + if ( ! stashingEnabled ) { + config.stillLoadingTimeout = 0 + self.log('service-worker', 'still-loading screen disabled, as there are no stashing plugins enabled') + } + // we're good! return true; @@ -693,19 +723,10 @@ let decrementActiveFetches = (clientId) => { // client has to be smart enough to know if that is just temporary // (and new fetches will fire in a moment, because a CSS file just // got fetched) or not - self.clients.get(clientId).then((client)=>{ - if (client !== null) { - try { - client.postMessage({ - allFetched: true - }) - } catch(err) { - self.log("service-worker", `postMessage failed for client: ${client}\n- Error message: ${err}`) - } - } - }) - .then(()=>{ - self.log('service-worker', 'all-fetched message sent.') + postMessage(clientId, { + allFetched: true + }).then(()=>{ + self.log('service-worker', 'all-fetched message queued.') }) } } @@ -744,7 +765,97 @@ let promiseTimeout = (time, timeout_resolves=false, error_message=false) => { /* ========================================================================= *\ -|* === LibResilientResourceInfo === *| +|* === LibResilientClient === *| +\* ========================================================================= */ + + +/** + * Libresilient client class + * + * handles communication with a client + * + * TODO: track active fetches as part of this class? + * TODO: https://gitlab.com/rysiekpl/libresilient/-/issues/83 + */ +let LibResilientClient = class { + + async postMessage(message) { + + // log + self.log('service-worker', 'postMessage():', JSON.stringify(message)) + + // add our message to the message queue + this.messageQueue.push(message) + + // try to get the client from Client API based on clientId + if (! this.client) { + this.client = await self + .clients + .get(this.clientId) + } + + // now, we might still not have a valid client here + if (! this.client) { + // store it for later, when we do get a valid client + self.log('service-worker', `postMessage(): no valid client for id: ${this.clientId}, added message to the queue`) + + // we have a valid client, it seems! + } else { + + // we want all messages to be delivered, and in order they were added + // our message is at the end and will get handled in due course + let msg = false + while (msg = this.messageQueue.shift()) { + try { + this.client.postMessage(msg); + } catch (err) { + // if we fail for whatever reason, bail from the loop + self.log('service-worker', `postMessage(): client seems valid, but postMessage failed; message left in the queue\n- Error message: ${err}`) + this.messageQueue.unshift(msg) + break + } + } + } + } + + constructor(clientId) { + + // we often get the clientId long before + // we are able to get a valid client out of it + // + // so we need to keep both + this.clientId = clientId + this.client = null; + + // queued messages for when we have a client available + this.messageQueue = [] + } +} + +// map of all known clients +let LibResilientClients = new Map() + +/** + * getting a client based on clientId and sending a message + * (or queueing it for later if we cannot get a valid client) + */ +let postMessage = async (clientId, message) => { + + // do we already have a LibResilientClient instance for that client id? + let client = LibResilientClients.get(clientId) + + // if not, create it + if (client === undefined) { + client = new LibResilientClient(clientId) + LibResilientClients.set(clientId, client) + } + + // send (or queue) the message + await client.postMessage(message) +} + +/* ========================================================================= *\ +|* === LibResilientResourceInfo === *| \* ========================================================================= */ @@ -768,34 +879,28 @@ let LibResilientResourceInfo = class { // actual values of the fields // only used internally, and stored into the Indexed DB this.values = { - url: '', // read only after initialization - clientId: null, - fetchError: null, - method: null, - state: null, // can be "error", "success", "running" + 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; + // queued messages for when we have a client available + this.messageQueue = [] + // set it this.values.url = url this.values.clientId = clientId // we might not have a non-empty clientId if it's a cross-origin fetch if (clientId) { - // get the client from Client API based on clientId - self.clients.get(clientId).then((client)=>{ - // set the client - this.client = client - // Send a message to the client - if (this.client !== null) { - try { - this.client.postMessage(this.values); - } catch(err) { - self.log("service-worker", `postMessage failed for client: ${this.client}\n- Error message: ${err}`) - } - } - }) + postMessage( + clientId, + {...this.values} + ) } } @@ -813,7 +918,7 @@ let LibResilientResourceInfo = class { Object .keys(data) .filter((k)=>{ - return ['fetchError', 'method', 'state'].includes(k) + return ['lastError', 'method', 'state'].includes(k) }) .forEach((k)=>{ msg += '\n+-- ' + k + ': ' + data[k] @@ -825,20 +930,19 @@ let LibResilientResourceInfo = class { }) self.log('service-worker', msg) // send the message to the client - if (this.client && changed && (this.client !== null)) { - try { - this.client.postMessage(this.values); - } catch(err) { - self.log("service-worker", `postMessage failed for client: ${this.client}\n- Error message: ${err}`) - } + if (changed) { + postMessage( + this.values.clientId, + {...this.values} + ); } } /** - * fetchError property + * lastError property */ - get fetchError() { - return this.values.fetchError + get lastError() { + return this.values.lastError } /** @@ -923,6 +1027,11 @@ let libresilientFetch = (plugin, url, init, reqInfo) => { '\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) + let timeout_promise, timeout_id [timeout_promise, timeout_id] = promiseTimeout( self.LibResilientConfig.defaultPluginTimeout, @@ -930,27 +1039,24 @@ let libresilientFetch = (plugin, url, init, reqInfo) => { `LibResilient request using ${plugin.name} timed out after ${self.LibResilientConfig.defaultPluginTimeout}ms.` ) - // race the plugin(s) vs. a timeout - let race_promise = Promise - .race([ - plugin.fetch(url, init), - timeout_promise - ]) - // making sure there are no dangling promises etc // - // this should happen asynchronously - race_promise + // 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! - .then(()=>{ + .finally(()=>{ clearTimeout(timeout_id) }) // no-op to make sure we don't end up with dangling rejected premises .catch((e)=>{}) - - // return the racing promise - return race_promise; + + // race the plugin(s) vs. the timeout + return Promise + .race([ + fetch_promise, + timeout_promise + ]); } @@ -1022,8 +1128,7 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt '\n+-- error : ' + error.toString()) // save info in reqInfo -- status of the previous method reqInfo.update({ - state: "error", - fetchError: error.toString() + lastError: error.toString() }) return libresilientFetch(currentPlugin, url, init, reqInfo) }) @@ -1039,7 +1144,10 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt decrementActiveFetches(clientId) // record the success - reqInfo.update({state:"success"}) + reqInfo.update({ + lastError: null, + state:"success" + }) // get the plugin that was used to fetch content let plugin = self.LibResilientPlugins.find(p=>p.name===reqInfo.method) @@ -1054,9 +1162,14 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt self.log('service-worker', 'starting background no-stashed fetch for:', url); // event.waitUntil? // https://stackoverflow.com/questions/37902441/what-does-event-waituntil-do-in-service-worker-and-why-is-it-needed/37906330#37906330 - getResourceThroughLibResilient(url, init, clientId, false, true, response.clone()).catch((e)=>{ - self.log('service-worker', 'background no-stashed fetch failed for:', url); - }) + try { + getResourceThroughLibResilient(url, init, clientId, false, true, response.clone()) + .catch((e)=>{ + self.log('service-worker', 'background no-stashed fetch failed for:', url); + }) + } catch(e) { + self.log('service-worker', 'background no-stashed fetch failed for:', url, `\n+-- error: ${e}`); + } // return the response so that stuff can keep happening return response @@ -1080,17 +1193,9 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt || ( stashedResponse.headers.get('X-LibResilient-ETag') !== response.headers.get('X-LibResilient-ETag') ) ) { // inform! self.log('service-worker', 'fetched version method or ETag differs from stashed for:', url) - self.clients.get(reqInfo.clientId).then((client)=>{ - if (client !== null) { - try { - client.postMessage({ - url: url, - fetchedDiffers: true - }) - } catch(err) { - self.log("service-worker", `postMessage failed for client: ${client}\n- Error message: ${err}`) - } - } + postMessage(reqInfo.clientId, { + url: url, + fetchedDiffers: true }) // TODO: this should probably modify doStash? } @@ -1134,18 +1239,14 @@ let getResourceThroughLibResilient = (url, init, clientId, useStashed=true, doSt }) // a final catch... in case all plugins fail .catch((err)=>{ - self.log('service-worker', "LibResilient also failed completely: ", err, + self.log('service-worker', "LibResilient failed completely: ", err, '\n+-- URL : ' + url) // cleanup reqInfo.update({ - state: "error", - fetchError: err.toString() + state: "failed", + lastError: err.toString() }) - // this is very naïve and should in fact be handled - // inside the relevant plugin, probably - // TODO: is this even needed? - reqInfo.update({method: null}) decrementActiveFetches(clientId) // rethrow throw err @@ -1159,14 +1260,15 @@ self.addEventListener('install', async (event) => { let init_promise = initServiceWorker() await event.waitUntil(init_promise) if (await init_promise === true) { + self.skipWaiting() // "COMMIT_UNKNOWN" will be replaced with commit ID - self.log('service-worker', "0. Installed LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); + self.log('service-worker', "installed LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); } }); self.addEventListener('activate', async event => { - self.log('service-worker', "1. Activated LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); - // TODO: should we do some plugin initialization here? + await event.waitUntil(self.clients.claim()) + self.log('service-worker', "activated LibResilient Service Worker (commit: COMMIT_UNKNOWN)."); }); self.addEventListener('fetch', async event => { @@ -1193,21 +1295,11 @@ self.addEventListener('fetch', async event => { // so let's also send the plugin list, why not // // *sigh* JS is great *sigh* - self.clients - .get(clientId) - .then((client)=>{ - if (client !== null) { - try { - client.postMessage({ - clientId: clientId, - plugins: self.LibResilientPlugins.map((p)=>{return p.name}), - serviceWorker: 'COMMIT_UNKNOWN' - }) - } catch(err) { - self.log("service-worker", `postMessage failed for client: ${client}\n- Error message: ${err}`) - } - } - }) + postMessage(clientId, { + clientId: clientId, + plugins: self.LibResilientPlugins.map((p)=>{return p.name}), + serviceWorker: 'COMMIT_UNKNOWN' + }) } // counter! @@ -1218,6 +1310,7 @@ self.addEventListener('fetch', async event => { // info self.log('service-worker', "Fetching!", "\n+-- url :", event.request.url, + "\n+-- mode :", event.request.mode, "\n+-- clientId :", event.clientId, "\n+-- resultingClientId:", event.resultingClientId, "\n +-- activeFetches[" + clientId + "]:", self.activeFetches.get(clientId) @@ -1253,7 +1346,159 @@ self.addEventListener('fetch', async event => { // GET requests to our own domain that are *not* #libresilient-info requests // get handled by plugins in case of an error - return getResourceThroughLibResilient(url, init, clientId) + 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' ) ) { + + self.log('service-worker', `handling a navigate request; still-loading timeout: ${self.LibResilientConfig.stillLoadingTimeout}.`) + + let slPromise, slTimeoutId + [slPromise, slTimeoutId] = promiseTimeout(self.LibResilientConfig.stillLoadingTimeout, true) + + // make sure to clear the timeout related to slPromise + // in case we manage to get the content through the plugins + lrPromise + .then(()=>{ + self.log('service-worker', `content retrieved; still-loading timeout cleared.`) + clearTimeout(slTimeoutId) + }) + + // 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 + ) + }) + ]) + + // 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; + } }()) });