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}
+
+
+
+
+
+
+ ${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'
}
})
}