diff --git a/src/sw.ts b/src/sw.ts index 3776abee..3faa278f 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,7 +1,7 @@ import { createVerifiedFetch, type VerifiedFetch } from '@helia/verified-fetch' import { dnsJsonOverHttps } from '@multiformats/dns/resolvers' import { HeliaServiceWorkerCommsChannel, type ChannelMessage } from './lib/channel.js' -import { getConfig } from './lib/config-db.js' +import { getConfig, type ConfigDb } from './lib/config-db.js' import { contentTypeParser } from './lib/content-type-parser.js' import { getRedirectUrl, isDeregisterRequest } from './lib/deregister-request.js' import { GenericIDB } from './lib/generic-db.js' @@ -46,6 +46,31 @@ interface LocalSwConfig { installTimestamp: number } +/** + * When returning a meaningful error page, we provide the following details about + * the service worker + */ +interface ServiceWorkerDetails { + config: ConfigDb + crossOriginIsolated: boolean + installTime: string + origin: string + scope: string + state: string +} + +/** + * When returning a meaningful error page, we provide the following details about + * the response that failed + */ +interface ResponseDetails { + responseBody: string + headers: Record + status: number + statusText: string + url: string +} + /** ****************************************************** * "globals" @@ -454,6 +479,10 @@ async function fetchHandler ({ path, request, event }: FetchHandlerArg): Promise * the response object, regardless of it's inner content */ clearTimeout(signalAbortTimeout) + if (response.status >= 400) { + log.error('fetchHandler: response not ok: ', response) + return await errorPageResponse(response) + } return response } catch (err: unknown) { const errorMessages: string[] = [] @@ -476,6 +505,98 @@ async function fetchHandler ({ path, request, event }: FetchHandlerArg): Promise } } +/** + * TODO: better styling + * TODO: more error details from @helia/verified-fetch + */ +async function errorPageResponse (fetchResponse: Response): Promise { + const responseContentType = fetchResponse.headers.get('Content-Type') + let json: Record | null = null + let text: string | null = null + const responseBodyAsText: string | null = await fetchResponse.text() + + if (responseContentType != null) { + if (responseContentType.includes('text/html')) { + return fetchResponse + } else if (responseContentType.includes('application/json')) { + // we may eventually provide error messaging from @helia/verified-fetch + try { + json = JSON.parse(responseBodyAsText) + } catch (e) { + text = responseBodyAsText + json = { error: { message: `${fetchResponse.statusText}: ${text}`, stack: null } } + } + } + } + if (json == null) { + text = await fetchResponse.text() + json = { error: { message: `${fetchResponse.statusText}: ${text}`, stack: null } } + } + + const responseDetails = getResponseDetails(fetchResponse, responseBodyAsText) + + /** + * TODO: output configuration + */ + return new Response(` +

Oops! Something went wrong inside of Service Worker IPFS Gateway.

+

+

+

Error details:

+

Message: ${json.error.message}

+ Stacktrace:
${json.error.stack}
+

+

+

Response details:

+

${responseDetails.status} ${responseDetails.statusText}

+
${JSON.stringify(responseDetails, null, 2)}
+

+

+

Service worker details:

+
${JSON.stringify(await getServiceWorkerDetails(), null, 2)}
+

+ `, { + status: fetchResponse.status, + statusText: fetchResponse.statusText, + headers: new Headers({ + 'Content-Type': 'text/html' + }) + }) +} + +/** + * TODO: more service worker details + */ +async function getServiceWorkerDetails (): Promise { + const registration = self.registration + const state = registration.installing?.state ?? registration.waiting?.state ?? registration.active?.state ?? 'unknown' + + return { + config: await getConfig(), + // TODO: implement https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy + crossOriginIsolated: self.crossOriginIsolated, + installTime: (new Date(firstInstallTime)).toUTCString(), + origin: self.location.origin, + scope: registration.scope, + state + } +} + +function getResponseDetails (response: Response, responseBody: string): ResponseDetails { + const headers = {} + response.headers.forEach((value, key) => { + headers[key] = value + }) + + return { + responseBody, + headers, + status: response.status, + statusText: response.statusText, + url: response.url + } +} + async function isTimebombExpired (): Promise { firstInstallTime = firstInstallTime ?? await getInstallTimestamp() const now = Date.now()