Merge branch 'wip-error-handling-revamp' into 'master'

Improved error handling

See merge request rysiekpl/libresilient!27
master
Michał "rysiek" Woźniak 2024-03-14 00:04:47 +00:00
commit 5b2f37c9a3
14 zmienionych plików z 800 dodań i 384 usunięć

Wyświetl plik

@ -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), '<!DOCTYPE html><html><head><title>Still loading...</title>')
assertEquals((await response.text()).slice(0, 55), '<!DOCTYPE html><html><head><title>Still loading</title>')
})
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), '<!DOCTYPE html><html><head><title>Loading failed.</title>')
})
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'
)
})
})

Wyświetl plik

@ -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.

Wyświetl plik

@ -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);

Wyświetl plik

@ -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);
});
})

Wyświetl plik

@ -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'
)
});
})

Wyświetl plik

@ -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()
*/

Wyświetl plik

@ -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:'
)
});
})

Wyświetl plik

@ -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);

Wyświetl plik

@ -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],

Wyświetl plik

@ -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'
)
});
})

Wyświetl plik

@ -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,

Wyświetl plik

@ -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'
)
});
})

Wyświetl plik

@ -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

Wyświetl plik

@ -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] = '<unknown>'
}
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.<br/><br/>This page will auto-reload in a few seconds. If it does not, please <a href='./'>click here</a>."
}
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 =
`<!DOCTYPE html><html><head><title>${init_msgs.title}</title></head><body>
<style>
body {
margin: auto;
width: 30em;
margin: 2em auto;
background: #dddddd;
color: black;
font-family: sans-serif;
}
h1 {
height: 1em;
margin-bottom:
0.2em
}
h1 > span {
animation: throbber 2s infinite 0s linear;
font-size: 70%;
position: relative;
top: 0.2em;
}
#throbber1 {
animation-delay: 0s;
}
#throbber2 {
animation-delay: 0.5s;
}
#throbber3 {
animation-delay: 1s;
}
@keyframes throbber {
0% {opacity: 1.0;}
50% {opacity: 0.1;}
100% {opacity: 1.0;}
}
#working {
color: gray;
font-family: monospace;
font-weight: bold;
display: flex;
flex-wrap: nowrap;
justify-content: left;
margin-top: 0em;
}
a {
color: #2d5589
}
#errors {
font-weight: bold;
font-size: smaller;
marin-top: 1em;
color: maroon;
opacity: 0.5;
font-family: monospace;
}
</style>
<h1 id="header">${init_msgs.title}`
if (success_msgs !== false) {
html += `<span id="throbber1">&#x2022;</span><span id="throbber2">&#x2022;</span><span id="throbber3">&#x2022;</span>`
}
html += `</h1>
<p id="working">attempts:&nbsp;<span id="status">1</span></p>
<p id="text">${init_msgs.body}</p>
<p id="errors"></p>
<script>
let attempts = 0;
let header = document.getElementById('header')
let text = document.getElementById('text')
let status = document.getElementById('status')
let errors = document.getElementById('errors')
navigator.serviceWorker.addEventListener('message', event => {
if ( event.data.url === window.location.href ) {`
// do we have any success messages?
// if not, ignore the whole success handling bit (including automatic reload!)
//
// try-catch because we really don't want an exception here
try {
if (success_msgs !== false) {
html += `
if ( event.data.state === 'success' ) {
header.innerHTML = "${ success_msgs.title }"
document.title = "${ success_msgs.title }"
text.innerHTML = "${ success_msgs.body }"
window.location.reload()
}`
}
} catch(e) {
self.log('service-worker', `templating the user-facing HTML threw an error: ${e}`)
}
// do we have any failure messages?
// if not, ignore the whole failure handling bit
//
// try-catch because we really don't want an exception here
try {
if (failure_msgs !== false) {
html += `
if ( event.data.state === 'failed' ) {
header.innerHTML = "${ failure_msgs.title }"
document.title = "${ failure_msgs.title }"
text.innerHTML = "${ failure_msgs.body }"
}`
}
} catch(e) {
self.log('service-worker', `templating the user-facing HTML threw an error: ${e}`)
}
// footer
html +=`
if ( 'error' in event.data ) {
attempts += 1;
status.innerHTML = attempts;
errors.innerHTML += '(' + event.data.method + ') ' + event.data.error + '<br/>'
}
}
})
</script></body></html>`
// 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 = `<!DOCTYPE html><html><head><title>Still loading...</title></head><body>
<style>
body {
margin: auto;
width: 30em;
margin: 2em auto;
background: #dddddd;
color: black;
font-family: sans-serif;
}
h1 {
height: 1em;
margin-bottom:
0.2em
}
h1 > span {
animation: throbber 2s infinite 0s linear;
font-size: 70%;
position: relative;
top: 0.2em;
}
#throbber1 {
animation-delay: 0s;
}
#throbber2 {
animation-delay: 0.5s;
}
#throbber3 {
animation-delay: 1s;
}
@keyframes throbber {
0% {opacity: 1.0;}
50% {opacity: 0.1;}
100% {opacity: 1.0;}
}
#working {
color: gray;
font-family: monospace;
font-weight: bold;
display: flex;
flex-wrap: nowrap;
justify-content: left;
margin-top: 0em;
}
a {
color: #2d5589
}
</style>
<h1 id="header">Still loading<span id="throbber1">&#x2022;</span><span id="throbber2">&#x2022;</span><span id="throbber3">&#x2022;</span></h1>
<p id="working">attempts:&nbsp;<span id="status">1</span></p>
<p id="text">The content is still being loaded, thank you for your patience.<br/><br/>This page will auto-reload in a few seconds. If it does not, please <a href="./">click here</a>.</p>
<script>
let attempts = 0;
let header = document.getElementById('header')
let text = document.getElementById('text')
let status = document.getElementById('status')
navigator.serviceWorker.addEventListener('message', event => {
if ( event.data.url === window.location.href ) {
if ( event.data.state === 'success' ) {
header.innerHTML = "Loaded, redirecting!"
text.innerHTML = "The content has loaded, you are being redirected."
window.location.reload()
} else if ( event.data.state === 'failed' ) {
header.innerHTML = "Loading failed."
text.innerHTML = "We're sorry, we were unable to load this page."
}
if ( ( 'lastError' in event.data ) && ( typeof event.data.lastError === 'string' ) ) {
attempts += 1;
status.innerHTML = attempts;
}
}
// it is enabled!
self.log('service-worker', `handling a navigate request; still-loading timeout: ${self.LibResilientConfig.stillLoadingTimeout}.`)
let slPromise, slTimeoutId
[slPromise, slTimeoutId] = promiseTimeout(self.LibResilientConfig.stillLoadingTimeout, true)
// make sure to clear the timeout related to slPromise
// in case we manage to get the content through the plugins
lrPromise
.then(()=>{
self.log('service-worker', `content retrieved; still-loading timeout cleared.`)
clearTimeout(slTimeoutId)
})
// prepare a Promise that races the "still loading" screen promise against the LibResilient plugins
finalPromise = Promise.race([
// regular fetch-through-plugins
lrPromise,
// the "still loading screen"
//
// this will delay a specified time, and ten return a Response
// with very basic HTML informing the user that the page is still loading,
// a Refresh header set, and a link for the user to reload the screen manually
slPromise
.then(()=>{
// inform
self.log('service-worker', 'handling a navigate request is taking too long, showing the still-loading screen')
// we need to create a new Response object
// with all the headers added explicitly,
// since response.headers is immutable
var responseInit = {
status: 202,
statusText: "Accepted",
headers: {},
url: url
};
responseInit.headers['Content-Type'] = "text/html"
responseInit.headers['X-LibResilient-Method'] = "still-loading"
// get the still-loading page contents
let stillLoadingHTML = getUserFacingHTML(
still_loading_messages,
success_messages,
failure_messages
)
let blob = new Blob(
[stillLoadingHTML],
{type: "text/html"}
)
return new Response(
blob,
responseInit
)
})
</script></body></html>`
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
}
}())
});