diff --git a/src/lib/error-pages.ts b/src/lib/error-pages.ts new file mode 100644 index 00000000..bfb3c030 --- /dev/null +++ b/src/lib/error-pages.ts @@ -0,0 +1,213 @@ +/** + * Error page HTML generation for IPFS Service Worker Gateway + * Generates inline error pages matching the original 504.html styling + */ + +import type { ErrorInfo, ErrorType } from './error-types.js' + +/** + * Configuration for error page generation + */ +export interface ErrorPageConfig { + status: number + statusText: string + url: string + cid: string | null + errorType: ErrorType | string + errorMessage: string + suggestions: string[] + stack?: string +} + +/** + * Escape HTML special characters to prevent XSS + */ +function escapeHtml (unsafe: string): string { + return unsafe + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, ''') +} + +const TACHYONS_CSS = ` + body { margin: 0; } + .f4 { font-size: 1rem; } + .f3 { font-size: 1.25rem; } + .f2 { font-size: 2rem; } + .fw2 { font-weight: 300; } + .pa2 { padding: .5rem; } + .pa3 { padding: 1rem; } + .pa4-l { padding: 2rem; } + .ma0 { margin: 0; } + .ma3 { margin: 1rem; } + .mv5-l { margin-top: 3rem; margin-bottom: 3rem; } + .mh0 { margin-left: 0; margin-right: 0; } + .mw7 { max-width: 60rem; } + .pb1 { padding-bottom: .25rem; } + .ph2 { padding-left: .5rem; padding-right: .5rem; } + .tc { text-align: center; } + .ttu { text-transform: uppercase; } + .v-top { vertical-align: top; } + .inline-flex { display: inline-flex; } + .items-center { align-items: center; } + .justify-between { justify-content: space-between; } + .flex { display: flex; } + .bn { border: none; } + .br2 { border-radius: .25rem; } + .bg-navy { background-color: #001b3a; } + .bg-snow { background-color: #fffffe; } + .bg-teal { background-color: #0b7285; } + .bb { border-bottom-style: solid; } + .bw3 { border-width: 3px; } + .b--aqua { border-color: #69c4cd; } + .aqua { color: #69c4cd; } + .gray { color: #8a8a8a; } + .white { color: #fff; } + .sans-serif { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; } + .no-underline { text-decoration: none; } + .dn { display: none; } + .dib { display: inline-block; } + .center { margin-left: auto; margin-right: auto; } + + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; + line-height: 1.5; + } + a { color: #0b3a53; } + ul { padding-left: 1.5rem; line-height: 1.6; } + strong { font-weight: 600; } + + @media screen and (min-width: 60em) { + .pa4-l { padding: 2rem; } + .mv5-l { margin-top: 3rem; margin-bottom: 3rem; } + } +` + +export function generateErrorPageHTML (config: ErrorPageConfig): string { + const { status, statusText, errorMessage, suggestions, cid } = config + + const suggestionsList = suggestions.map(s => `
  • ${escapeHtml(s)}
  • `).join('') + + return ` + + + + ${status} ${statusText} + + + +
    +
    + + + +
    +
    +

    Service Worker Gateway (beta)

    +
    +
    +
    +
    + ${status} ${escapeHtml(statusText)} +
    +
    + ${cid +? ` +

    Requested CID:

    +

    ${escapeHtml(cid)}

    + ` +: ''} + + +

    What went wrong:

    +

    ${escapeHtml(errorMessage)}

    + + + ${suggestions.length > 0 +? ` +

    How you can proceed:

    + + ` +: ''} + +

    Additional resources:

    + + + ${cid ? `Debug retrievability of CID` : ''} +
    +
    + + +` +} + +/** + * Generate error page from ErrorInfo object + * + * @param errorInfo - Error information from detectErrorType + * @param url - Request URL + * @param cid - Extracted CID (optional) + * @param stack - Stack trace (optional) + * @returns Complete HTML error page + */ +export function generateErrorPageFromInfo ( + errorInfo: ErrorInfo, + url: string, + cid: string | null, + stack?: string +): string { + return generateErrorPageHTML({ + status: errorInfo.statusCode, + statusText: getStatusText(errorInfo.statusCode), + url, + cid, + errorType: errorInfo.errorType, + errorMessage: errorInfo.errorMessage, + suggestions: errorInfo.suggestions, + stack + }) +} + +/** + * Get HTTP status text for status code + */ +function getStatusText (status: number): string { + const statusTexts: Record = { + 400: 'Bad Request', + 404: 'Not Found', + 415: 'Unsupported Media Type', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout' + } + return statusTexts[status] || 'Error' +} diff --git a/src/lib/error-types.ts b/src/lib/error-types.ts new file mode 100644 index 00000000..b9ed0fff --- /dev/null +++ b/src/lib/error-types.ts @@ -0,0 +1,171 @@ +/** + * Error type detection and categorization for IPFS Service Worker Gateway + * + * This module analyzes errors and provides user-friendly explanations with + * actionable suggestions for resolution. + * + * @module error-types + */ + +/** + * Structured error information with user-facing details + */ +export interface ErrorInfo { + /** Machine-readable error category */ + errorType: ErrorType + /** User-friendly error explanation */ + errorMessage: string + /** Actionable suggestions to resolve the issue */ + suggestions: string[] + /** HTTP status code to return */ + statusCode: number +} + +/** + * Supported error categories + */ +export enum ErrorType { + HASH_VERIFICATION_FAILED = 'Hash Verification Failed', + NO_PROVIDERS = 'No Providers Found', + TIMEOUT = 'Request Timeout', + NETWORK_ERROR = 'Network Error', + INVALID_CID = 'Invalid CID', + UNSUPPORTED_FORMAT = 'Unsupported Content Type', + UNKNOWN = 'Unknown Error' +} + +/** + * Analyze an error and return categorized information + * + * @param error - Error object or error message string + * @returns Structured error information with suggestions + */ +export function detectErrorType (error: Error | string): ErrorInfo { + const errorMsg = typeof error === 'string' ? error : error.message + const errorMsgLower = errorMsg.toLowerCase() + + // Hash verification error - critical data integrity issue + if (errorMsgLower.includes('hash') && (errorMsgLower.includes('match') || errorMsgLower.includes('verif'))) { + return { + errorType: ErrorType.HASH_VERIFICATION_FAILED, + errorMessage: 'The downloaded block\'s hash did not match the requested CID. This indicates data corruption or a security issue.', + suggestions: [ + 'The content may have been corrupted during transmission', + 'Try accessing the content from a different IPFS gateway', + 'Clear your browser cache and retry', + 'If you control this content, verify its integrity and re-pin to IPFS' + ], + statusCode: 502 // Bad Gateway - upstream returned invalid data + } + } + + // No providers error - content unavailable on network + if (errorMsgLower.includes('no provider') || errorMsgLower.includes('no peers') || errorMsgLower.includes('could not find')) { + return { + errorType: ErrorType.NO_PROVIDERS, + errorMessage: 'No nodes on the IPFS network are currently hosting this content.', + suggestions: [ + 'The content may have been unpinned from all hosting nodes', + 'Wait a few minutes and try again as nodes may come online', + 'Verify the CID is correct and the content was properly published', + 'Check if the content is available on public gateways like ipfs.io' + ], + statusCode: 404 // Not Found - content doesn't exist on network + } + } + + // Timeout error - slow retrieval + if (errorMsgLower.includes('timeout') || errorMsgLower.includes('aborted')) { + return { + errorType: ErrorType.TIMEOUT, + errorMessage: 'The request took too long to complete and was aborted.', + suggestions: [ + 'The content may be hosted on slow or geographically distant nodes', + 'Try again in a few moments', + 'Check your internet connection', + 'Try accessing via a public IPFS gateway', + 'Consider increasing the timeout in the config page' + ], + statusCode: 504 // Gateway Timeout + } + } + + // Network error - connectivity issues + if (errorMsgLower.includes('network') || errorMsgLower.includes('fetch failed') || errorMsgLower.includes('connection')) { + return { + errorType: ErrorType.NETWORK_ERROR, + errorMessage: 'A network error occurred while fetching the content.', + suggestions: [ + 'Check your internet connection', + 'Try refreshing the page', + 'The IPFS network may be experiencing temporary issues', + 'Try again in a few moments' + ], + statusCode: 503 // Service Unavailable + } + } + + // Invalid CID - malformed identifier + if (errorMsgLower.includes('invalid') && errorMsgLower.includes('cid')) { + return { + errorType: ErrorType.INVALID_CID, + errorMessage: 'The provided CID is not valid or properly formatted.', + suggestions: [ + 'Verify the CID is correctly formatted (starts with "bafy" or "Qm")', + 'Ensure you copied the complete CID without truncation', + 'Try accessing different content to verify the gateway is working', + 'Learn about CID formats at https://cid.ipfs.tech' + ], + statusCode: 400 // Bad Request + } + } + + // Unsupported format + if (errorMsgLower.includes('unsupported') || errorMsgLower.includes('codec')) { + return { + errorType: ErrorType.UNSUPPORTED_FORMAT, + errorMessage: 'The content uses a format or codec not supported by this gateway.', + suggestions: [ + 'The content may use newer IPFS features not yet supported', + 'Try accessing it from a different IPFS implementation', + 'Check the IPFS documentation for supported content types' + ], + statusCode: 415 // Unsupported Media Type + } + } + + // Default/unknown error + return { + errorType: ErrorType.UNKNOWN, + errorMessage: errorMsg, + suggestions: [ + 'Try refreshing the page', + 'Check the browser console for technical details', + 'Try accessing the content from a public IPFS gateway', + 'Report this issue at https://github.com/ipfs/service-worker-gateway/issues' + ], + statusCode: 500 // Internal Server Error + } +} + +/** + * Extract CID from URL in subdomain or path format + * + * @param url - URL object to parse + * @returns Extracted CID or null if not found + */ +export function extractCIDFromURL (url: URL): string | null { + // Subdomain format: .ipfs.domain.com or .ipns.domain.com + const subdomainMatch = url.hostname.match(/^(.+?)\.(ipfs|ipns)\./) + if (subdomainMatch) { + return subdomainMatch[1] + } + + // Path format: /ipfs/ or /ipns/ + const pathMatch = url.pathname.match(/^\/(ipfs|ipns)\/([^/?#]+)/) + if (pathMatch) { + return pathMatch[2] + } + + return null +} diff --git a/src/sw.ts b/src/sw.ts index 496b9e0a..6a846fa7 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,5 +1,7 @@ import { getConfig } from './lib/config-db.js' import { HASH_FRAGMENTS, QUERY_PARAMS } from './lib/constants.js' +import { generateErrorPageHTML, generateErrorPageFromInfo } from './lib/error-pages.ts' +import { detectErrorType, extractCIDFromURL } from './lib/error-types.ts' import { getHeliaSwRedirectUrl } from './lib/first-hit-helpers.js' import { GenericIDB } from './lib/generic-db.js' import { getSubdomainParts } from './lib/get-subdomain-parts.js' @@ -614,29 +616,66 @@ async function fetchHandler ({ path, request, event }: FetchHandlerArg): Promise } catch (err: unknown) { log.trace('fetchHandler: error: ', err) const errorMessages: string[] = [] + let firstError: Error | null = null if (isAggregateError(err)) { log.error('fetchHandler aggregate error: ', err.message) for (const e of err.errors) { errorMessages.push(e.message) log.error('fetchHandler errors: ', e) + if (firstError == null) { + firstError = e + } } } else { - errorMessages.push(err instanceof Error ? err.message : JSON.stringify(err)) + const error = err instanceof Error ? err : new Error(JSON.stringify(err)) + errorMessages.push(error.message) log.error('fetchHandler error: ', err) + firstError = error } const errorMessage = errorMessages.join('\n') if (errorMessage.includes('aborted') || signal.aborted) { return await get504Response(event) } - const response = new Response('Service Worker IPFS Gateway error: ' + errorMessage, { status: 500 }) - response.headers.set('ipfs-sw', 'true') - return response + + const errorInfo = detectErrorType(firstError ?? errorMessage) + const url = new URL(event.request.url) + const cid = extractCIDFromURL(url) + + const errorHtml = generateErrorPageFromInfo( + errorInfo, + url.href, + cid, + firstError?.stack + ) + + return new Response(errorHtml, { + status: errorInfo.statusCode, + statusText: getStatusText(errorInfo.statusCode), + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'ipfs-sw': 'true', + 'Cache-Control': 'no-cache, no-store, must-revalidate' + } + }) } finally { clearTimeout(signalAbortTimeout) } } +function getStatusText (status: number): string { + const statusTexts: Record = { + 400: 'Bad Request', + 404: 'Not Found', + 415: 'Unsupported Media Type', + 500: 'Internal Server Error', + 502: 'Bad Gateway', + 503: 'Service Unavailable', + 504: 'Gateway Timeout' + } + return statusTexts[status] || 'Error' +} + /** * TODO: better styling * TODO: more error details from @helia/verified-fetch @@ -677,6 +716,7 @@ async function errorPageResponse (fetchResponse: Response): Promise { const mergedHeaders = new Headers(fetchResponse.headers) mergedHeaders.set('Content-Type', 'text/html') mergedHeaders.set('ipfs-sw', 'true') + mergedHeaders.set('Cache-Control', 'no-cache, no-store, must-revalidate') return new Response(` @@ -716,13 +756,32 @@ async function errorPageResponse (fetchResponse: Response): Promise { } async function get504Response (event: FetchEvent): Promise { - const response504 = await fetch(new URL('/ipfs-sw-504.html', event.request.url)) + const url = new URL(event.request.url) + const cid = extractCIDFromURL(url) + + const errorHtml = generateErrorPageHTML({ + status: 504, + statusText: 'Gateway Timeout', + url: url.href, + cid, + errorType: 'Timeout', + errorMessage: 'The gateway timed out while trying to fetch your content from the IPFS network.', + suggestions: [ + 'Wait a few moments and click the Retry button', + 'The content may be hosted on slow or distant nodes', + 'Try accessing the content from a public IPFS gateway', + 'Check if the content is still available on the IPFS network', + 'Consider increasing the timeout in the config page' + ] + }) - return new Response(response504.body, { + return new Response(errorHtml, { status: 504, + statusText: 'Gateway Timeout', headers: { - 'Content-Type': 'text/html', - 'ipfs-sw': 'true' + 'Content-Type': 'text/html; charset=utf-8', + 'ipfs-sw': 'true', + 'Cache-Control': 'no-cache, no-store, must-revalidate' } }) }