diff --git a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts index cb205b13d..f177304fc 100644 --- a/apps/ottabase-template-app-tanstack/cloudflare-worker.ts +++ b/apps/ottabase-template-app-tanstack/cloudflare-worker.ts @@ -1,5 +1,6 @@ import { RealtimeActor } from '@ottabase/cf-realtime/server'; import { errorResponse, ServiceError } from '@ottabase/utils/http-errors'; +import { ErrorPage } from '@ottabase/error-page'; import type { CloudflareEnv } from './cloudflare-env'; import { queueHandler } from './ottabase/queue'; import { handleBootstrapRoute, interceptIfNotReady, resolvePlatformState } from './worker/bootstrap'; @@ -140,12 +141,42 @@ export default { } catch (err) { console.error('Worker unhandled error:', err); - if (err instanceof ServiceError) { - return errorResponse(err.message, err.status, err.toApiResponse()); + const status = err instanceof ServiceError ? err.status : 500; + const isDev = + !(env as Record).ENVIRONMENT || + (env as Record).ENVIRONMENT === 'development' || + (env as Record).ENVIRONMENT === 'dev'; + + // For non-HTML requests (API calls), always return JSON + if (!isHtmlRequest(request)) { + if (err instanceof ServiceError) { + return errorResponse(err.message, err.status, err.toApiResponse()); + } + return errorResponse( + isDev && err instanceof Error ? err.message : 'An unexpected error occurred', + 500, + { code: 'INTERNAL_SERVER_ERROR' }, + ); + } + + // For HTML requests: show detailed dev error page or clean production page + const errorPage = new ErrorPage(); + if (isDev) { + errorPage.addRequestMetadata(request); + const html = errorPage.toHTML(err, { title: 'Worker Error' }); + return new Response(html, { + status, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); } - return errorResponse(err instanceof Error ? err.message : 'An unexpected error occurred', 500, { - code: 'INTERNAL_SERVER_ERROR', + // Production: minimal error page without internal details + const html = errorPage.toProductionHTML(status, { + title: err instanceof ServiceError ? err.message : undefined, + }); + return new Response(html, { + status, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, }); } }, diff --git a/apps/ottabase-template-app-tanstack/package.json b/apps/ottabase-template-app-tanstack/package.json index 2e3ea254e..5e4e44682 100644 --- a/apps/ottabase-template-app-tanstack/package.json +++ b/apps/ottabase-template-app-tanstack/package.json @@ -61,6 +61,7 @@ "@ottabase/ui-shadcn": "workspace:*", "@ottabase/ui-split-pane": "workspace:*", "@ottabase/utils": "workspace:*", + "@ottabase/error-page": "workspace:*", "@tabler/icons-react": "catalog:", "@tanstack/react-query": "catalog:", "@tanstack/react-router": "catalog:", diff --git a/packages/error-page/.gitignore b/packages/error-page/.gitignore new file mode 100644 index 000000000..9f6d627ea --- /dev/null +++ b/packages/error-page/.gitignore @@ -0,0 +1,2 @@ +dist/ +.turbo/ diff --git a/packages/error-page/README.md b/packages/error-page/README.md new file mode 100644 index 000000000..6839b3ba5 --- /dev/null +++ b/packages/error-page/README.md @@ -0,0 +1,149 @@ +# @ottabase/error-page + +Pretty print JavaScript errors as self-contained HTML pages — edge-runtime compatible. + +Built for Cloudflare Workers and other edge runtimes where Node.js `fs` is unavailable. + +## Features + +- 🔥 Beautiful error pages with error type badges, expandable stack frames, and "Open in editor" links +- 🌗 Dark/light theme toggle (respects `prefers-color-scheme`) +- 📋 Request metadata display (method, URL, headers with sensitive masking) +- ⛓️ Error cause chain rendering (recursive) +- 🏷️ Extra error properties shown as inline badges +- 📄 Stack Trace / Raw JSON tab switching with copy-to-clipboard +- 🕒 Timestamp display for when the error occurred +- 🛡️ XSS-safe HTML escaping, circular reference handling +- ☁️ Zero Node.js dependencies — works in Cloudflare Workers and other edge runtimes + +## Usage + +```ts +import { ErrorPage } from '@ottabase/error-page'; + +try { + await handleRequest(); +} catch (error) { + const errorPage = new ErrorPage(); + errorPage.addRequestMetadata(request); + + const html = errorPage.toHTML(error, { title: 'Worker Error' }); + return new Response(html, { + status: 500, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); +} +``` + +## API + +### `new ErrorPage()` + +Create a new ErrorPage instance. + +### `errorPage.toHTML(error, options?)` + +Render an error to a self-contained HTML page with: + +- Error type badge and message +- Expandable stack trace frames (app frames highlighted) +- Raw JSON error view with copy-to-clipboard +- Error cause chain +- Metadata sections +- Dark/light theme toggle +- "Open in editor" links +- Timestamp + +**Options:** + +| Option | Type | Default | Description | +| ---------- | -------- | ------------------------- | -------------------------------------- | +| `title` | `string` | `"An error has occurred"` | Page title / subtitle | +| `ide` | `string` | `"vscode"` | Code editor for file links | +| `offset` | `number` | `0` | Stack frames to skip | +| `cspNonce` | `string` | — | CSP nonce for inline style/script tags | + +Supported editors: `vscode`, `sublime`, `atom`, `phpstorm`, `textmate`, `emacs`, `macvim`, or a custom URL template with +`%f`, `%l`, `%c` placeholders. + +### `errorPage.group(name, sections)` + +Add metadata sections (e.g., request info, environment). + +```ts +errorPage.group('Request', { + info: [ + { key: 'Method', value: 'POST' }, + { key: 'URL', value: '/api/users' }, + ], + headers: [ + { key: 'content-type', value: 'application/json' }, + { key: 'user-agent', value: request.headers.get('user-agent') }, + ], +}); +``` + +### `errorPage.addRequestMetadata(request)` + +Automatically extract method, URL, and common headers from a `Request` object. Sensitive headers (`authorization`, +`cookie`) are automatically masked. + +### `errorPage.toProductionHTML(status, options?)` + +Render a minimal, production-safe error page. Does not expose stack traces, internal paths, or error details. + +```ts +const html = errorPage.toProductionHTML(500, { + title: 'Service Unavailable', + message: 'We are currently undergoing maintenance.', +}); +``` + +### `errorPage.parse(error, offset?)` + +Parse an error into a structured `ParsedError` object without rendering HTML. + +### `parseError(error, offset?)` + +Standalone function to parse errors into structured objects. + +### `renderHTML(parsedError, metadata?, options?)` + +Standalone function to render a `ParsedError` to HTML. + +### `renderProductionHTML(status, options?)` + +Standalone function to render a minimal production error page. + +## Integration with Cloudflare Worker + +```ts +import { ErrorPage } from '@ottabase/error-page'; + +export default { + async fetch(request: Request, env: Env): Promise { + try { + return await handleRequest(request, env); + } catch (err) { + const isDev = !env.ENVIRONMENT || env.ENVIRONMENT === 'development'; + const errorPage = new ErrorPage(); + + if (isDev) { + errorPage.addRequestMetadata(request); + const html = errorPage.toHTML(err, { title: 'Worker Error' }); + return new Response(html, { + status: 500, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + + // Production: clean error page without internals + const html = errorPage.toProductionHTML(500); + return new Response(html, { + status: 500, + headers: { 'Content-Type': 'text/html; charset=utf-8' }, + }); + } + }, +}; +``` diff --git a/packages/error-page/package.json b/packages/error-page/package.json new file mode 100644 index 000000000..33d0963f4 --- /dev/null +++ b/packages/error-page/package.json @@ -0,0 +1,51 @@ +{ + "name": "@ottabase/error-page", + "version": "0.0.1", + "description": "Pretty print JavaScript errors as self-contained HTML pages — edge-runtime compatible", + "author": "Ottabase", + "license": "MIT", + "type": "module", + "sideEffects": false, + "main": "./dist/index.cjs", + "module": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "files": [ + "dist", + "README.md" + ], + "keywords": [ + "error", + "pretty-error", + "error-page", + "edge-runtime", + "cloudflare-workers", + "ottabase" + ], + "scripts": { + "build": "tsup", + "dev": "tsup --watch", + "lint": "eslint src --ext .ts", + "type-check": "tsc --noEmit", + "test": "vitest", + "test:coverage": "vitest --coverage", + "clean": "rimraf dist" + }, + "devDependencies": { + "@types/node": "catalog:", + "eslint": "catalog:", + "rimraf": "catalog:", + "tsup": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/error-page/src/__tests__/error-page.test.ts b/packages/error-page/src/__tests__/error-page.test.ts new file mode 100644 index 000000000..a0a847b5e --- /dev/null +++ b/packages/error-page/src/__tests__/error-page.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { ErrorPage } from '../index'; + +describe('ErrorPage', () => { + it('should create an instance', () => { + const errorPage = new ErrorPage(); + expect(errorPage).toBeInstanceOf(ErrorPage); + }); + + it('should render error to HTML', () => { + const errorPage = new ErrorPage(); + const html = errorPage.toHTML(new Error('Test error')); + + expect(html).toContain(''); + expect(html).toContain('Test error'); + expect(html).toContain('Error'); + }); + + it('should accept metadata groups', () => { + const errorPage = new ErrorPage(); + errorPage.group('Request', { + info: [ + { key: 'Method', value: 'POST' }, + { key: 'URL', value: '/api/users' }, + ], + }); + + const html = errorPage.toHTML(new Error('Not found')); + + expect(html).toContain('Request'); + expect(html).toContain('POST'); + expect(html).toContain('/api/users'); + }); + + it('should merge metadata groups with the same name', () => { + const errorPage = new ErrorPage(); + errorPage.group('Request', { + info: [{ key: 'Method', value: 'GET' }], + }); + errorPage.group('Request', { + headers: [{ key: 'Host', value: 'example.com' }], + }); + + const html = errorPage.toHTML(new Error('Test')); + + expect(html).toContain('Method'); + expect(html).toContain('Host'); + expect(html).toContain('example.com'); + }); + + it('should support chaining on group()', () => { + const errorPage = new ErrorPage(); + const result = errorPage.group('Test', { a: [{ key: 'k', value: 'v' }] }); + + expect(result).toBe(errorPage); + }); + + it('should parse errors independently via parse()', () => { + const errorPage = new ErrorPage(); + const parsed = errorPage.parse(new TypeError('Cannot read')); + + expect(parsed.type).toBe('TypeError'); + expect(parsed.message).toBe('Cannot read'); + expect(parsed.frames.length).toBeGreaterThan(0); + }); + + it('should pass HTML options through', () => { + const errorPage = new ErrorPage(); + const html = errorPage.toHTML(new Error('Test'), { + title: 'Custom Title', + ide: 'sublime', + cspNonce: 'test-nonce', + }); + + expect(html).toContain('Custom Title'); + expect(html).toContain('subl://open'); + expect(html).toContain('nonce="test-nonce"'); + }); + + it('should handle non-Error values', () => { + const errorPage = new ErrorPage(); + const html = errorPage.toHTML('string error'); + + expect(html).toContain('string error'); + }); + + it('should handle error with cause', () => { + const errorPage = new ErrorPage(); + const cause = new Error('Database connection failed'); + const error = new Error('Service unavailable', { cause }); + const html = errorPage.toHTML(error); + + expect(html).toContain('Service unavailable'); + expect(html).toContain('Database connection failed'); + expect(html).toContain('Error Cause'); + }); + + it('should handle error with extra properties', () => { + const errorPage = new ErrorPage(); + const error = new Error('Bad request'); + (error as Record).status = 400; + (error as Record).code = 'VALIDATION_ERROR'; + + const html = errorPage.toHTML(error); + + expect(html).toContain('400'); + expect(html).toContain('VALIDATION_ERROR'); + }); + + it('should add request metadata from Request object', () => { + const errorPage = new ErrorPage(); + const request = new Request('https://example.com/api/test?q=1', { + method: 'POST', + headers: { + 'user-agent': 'TestAgent/1.0', + 'content-type': 'application/json', + }, + }); + + errorPage.addRequestMetadata(request); + const html = errorPage.toHTML(new Error('Test')); + + expect(html).toContain('POST'); + expect(html).toContain('/api/test'); + expect(html).toContain('TestAgent/1.0'); + }); + + it('should mask sensitive headers in request metadata', () => { + const errorPage = new ErrorPage(); + const request = new Request('https://example.com/', { + headers: { + authorization: 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret', + }, + }); + + errorPage.addRequestMetadata(request); + const html = errorPage.toHTML(new Error('Test')); + + // Should mask the middle of the token + expect(html).toContain('Bear'); + expect(html).toContain('****'); + expect(html).not.toContain('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.secret'); + }); + + it('should support offset option to skip frames', () => { + const errorPage = new ErrorPage(); + + const htmlNoOffset = errorPage.toHTML(new Error('Test'), { offset: 0 }); + const htmlWithOffset = errorPage.toHTML(new Error('Test'), { offset: 5 }); + + // Both should be valid HTML, but offset version may have fewer frames + expect(htmlNoOffset).toContain(''); + expect(htmlWithOffset).toContain(''); + }); + + it('should render production-safe error page via toProductionHTML', () => { + const errorPage = new ErrorPage(); + const html = errorPage.toProductionHTML(500); + + expect(html).toContain(''); + expect(html).toContain('500'); + expect(html).toContain('Server Error'); + expect(html).not.toContain('Stack Trace'); + }); + + it('should render production page with custom title', () => { + const errorPage = new ErrorPage(); + const html = errorPage.toProductionHTML(403, { title: 'Forbidden' }); + + expect(html).toContain('403'); + expect(html).toContain('Forbidden'); + }); +}); diff --git a/packages/error-page/src/__tests__/parser.test.ts b/packages/error-page/src/__tests__/parser.test.ts new file mode 100644 index 000000000..6a046ed3d --- /dev/null +++ b/packages/error-page/src/__tests__/parser.test.ts @@ -0,0 +1,111 @@ +import { describe, it, expect } from 'vitest'; +import { parseError } from '../parser'; + +describe('parseError', () => { + it('should parse a standard Error', () => { + const error = new Error('Test error message'); + const parsed = parseError(error); + + expect(parsed.type).toBe('Error'); + expect(parsed.message).toBe('Test error message'); + expect(parsed.frames.length).toBeGreaterThan(0); + expect(parsed.rawStack).toBeDefined(); + expect(parsed.cause).toBeUndefined(); + }); + + it('should parse a TypeError', () => { + const error = new TypeError('Cannot read property'); + const parsed = parseError(error); + + expect(parsed.type).toBe('TypeError'); + expect(parsed.message).toBe('Cannot read property'); + }); + + it('should parse error with cause', () => { + const cause = new Error('Root cause'); + const error = new Error('Wrapper error', { cause }); + const parsed = parseError(error); + + expect(parsed.cause).toBeDefined(); + expect(parsed.cause!.type).toBe('Error'); + expect(parsed.cause!.message).toBe('Root cause'); + }); + + it('should handle non-Error values (string)', () => { + const parsed = parseError('something went wrong'); + + expect(parsed.type).toBe('string'); + expect(parsed.message).toBe('something went wrong'); + expect(parsed.frames).toHaveLength(0); + }); + + it('should handle non-Error values (number)', () => { + const parsed = parseError(42); + + expect(parsed.type).toBe('number'); + expect(parsed.message).toBe('42'); + }); + + it('should handle non-Error values (object)', () => { + const parsed = parseError({ code: 'FAIL', detail: 'bad request' }); + + expect(parsed.type).toBe('Object'); + expect(parsed.properties).toHaveProperty('code', 'FAIL'); + }); + + it('should handle null/undefined', () => { + expect(parseError(null).message).toBe('null'); + expect(parseError(undefined).message).toBe('undefined'); + }); + + it('should apply offset to skip frames', () => { + const error = new Error('Test'); + const allFrames = parseError(error, 0); + const offsetFrames = parseError(error, 2); + + expect(offsetFrames.frames.length).toBe(Math.max(0, allFrames.frames.length - 2)); + }); + + it('should extract extra properties from error objects', () => { + const error = new Error('Custom error'); + (error as Record).code = 'CUSTOM_CODE'; + (error as Record).status = 422; + + const parsed = parseError(error); + + expect(parsed.properties).toHaveProperty('code', 'CUSTOM_CODE'); + expect(parsed.properties).toHaveProperty('status', 422); + }); + + it('should mark node_modules frames as non-app', () => { + const error = new Error('Test'); + const parsed = parseError(error); + + for (const frame of parsed.frames) { + if (frame.file?.includes('node_modules')) { + expect(frame.isApp).toBe(false); + } + } + }); + + it('should parse frames with file, line, and column', () => { + const error = new Error('Test'); + const parsed = parseError(error); + + // At least the first frame should have file/line/column info + const framesWithInfo = parsed.frames.filter((f) => f.file && f.line && f.column); + expect(framesWithInfo.length).toBeGreaterThan(0); + }); + + it('should handle deeply nested causes', () => { + const deepCause = new Error('Level 3'); + const midCause = new Error('Level 2', { cause: deepCause }); + const error = new Error('Level 1', { cause: midCause }); + + const parsed = parseError(error); + + expect(parsed.cause).toBeDefined(); + expect(parsed.cause!.cause).toBeDefined(); + expect(parsed.cause!.cause!.message).toBe('Level 3'); + }); +}); diff --git a/packages/error-page/src/__tests__/renderer.test.ts b/packages/error-page/src/__tests__/renderer.test.ts new file mode 100644 index 000000000..68a069231 --- /dev/null +++ b/packages/error-page/src/__tests__/renderer.test.ts @@ -0,0 +1,247 @@ +import { describe, it, expect } from 'vitest'; +import { renderHTML } from '../renderer'; +import type { ParsedError, MetadataGroup } from '../types'; + +describe('renderHTML', () => { + const makeParsedError = (overrides: Partial = {}): ParsedError => ({ + type: 'Error', + message: 'Something went wrong', + frames: [ + { + raw: ' at doSomething (/app/src/handler.ts:42:10)', + file: '/app/src/handler.ts', + line: 42, + column: 10, + function: 'doSomething', + isApp: true, + }, + { + raw: ' at Module._compile (node:internal/modules/cjs/loader:1241:14)', + file: 'node:internal/modules/cjs/loader', + line: 1241, + column: 14, + function: 'Module._compile', + isApp: false, + }, + ], + properties: {}, + ...overrides, + }); + + it('should return a valid HTML document', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('should include error type and message', () => { + const html = renderHTML(makeParsedError({ type: 'TypeError', message: 'null is not an object' })); + + expect(html).toContain('TypeError'); + expect(html).toContain('null is not an object'); + }); + + it('should include stack frames', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('doSomething'); + expect(html).toContain('/app/src/handler.ts'); + expect(html).toContain('42:10'); + }); + + it('should mark app frames with badge', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('is-app'); + expect(html).toContain('ep-app-badge'); + }); + + it('should include editor links with vscode by default', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('vscode://file/'); + }); + + it('should use custom IDE when specified', () => { + const html = renderHTML(makeParsedError(), [], { ide: 'sublime' }); + + expect(html).toContain('subl://open'); + }); + + it('should include dark/light theme toggle', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('ep-theme-toggle'); + expect(html).toContain('html.dark'); + }); + + it('should include Stack Trace and Raw tabs', () => { + const html = renderHTML(makeParsedError()); + + expect(html).toContain('Stack Trace'); + expect(html).toContain('Raw'); + }); + + it('should include raw JSON output with error data', () => { + const html = renderHTML(makeParsedError({ type: 'RangeError', message: 'out of bounds' })); + + expect(html).toContain('RangeError'); + expect(html).toContain('out of bounds'); + }); + + it('should render error cause when present', () => { + const parsed = makeParsedError({ + cause: { + type: 'DatabaseError', + message: 'Connection refused', + frames: [], + properties: {}, + }, + }); + const html = renderHTML(parsed); + + expect(html).toContain('Error Cause'); + expect(html).toContain('DatabaseError'); + expect(html).toContain('Connection refused'); + }); + + it('should render metadata groups', () => { + const metadata: MetadataGroup[] = [ + { + name: 'Request', + sections: { + info: [ + { key: 'Method', value: 'GET' }, + { key: 'URL', value: 'https://example.com/api/test' }, + ], + headers: [{ key: 'user-agent', value: 'Mozilla/5.0' }], + }, + }, + ]; + const html = renderHTML(makeParsedError(), metadata); + + expect(html).toContain('Request'); + expect(html).toContain('Method'); + expect(html).toContain('GET'); + expect(html).toContain('user-agent'); + expect(html).toContain('Mozilla/5.0'); + }); + + it('should render error properties as badges', () => { + const parsed = makeParsedError({ + properties: { code: 'NOT_FOUND', status: 404 }, + }); + const html = renderHTML(parsed); + + expect(html).toContain('code'); + expect(html).toContain('NOT_FOUND'); + expect(html).toContain('status'); + expect(html).toContain('404'); + }); + + it('should include custom title', () => { + const html = renderHTML(makeParsedError(), [], { title: 'Server Error' }); + + expect(html).toContain('Server Error'); + }); + + it('should add CSP nonce to style and script tags', () => { + const html = renderHTML(makeParsedError(), [], { cspNonce: 'abc123' }); + + expect(html).toContain('nonce="abc123"'); + }); + + it('should handle empty frames gracefully', () => { + const html = renderHTML(makeParsedError({ frames: [] })); + + expect(html).toContain('No stack frames available'); + }); + + it('should escape HTML in error messages', () => { + const html = renderHTML(makeParsedError({ message: '' })); + + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); +}); + +describe('renderProductionHTML', () => { + let renderProductionHTML: typeof import('../renderer').renderProductionHTML; + + beforeAll(async () => { + const mod = await import('../renderer'); + renderProductionHTML = mod.renderProductionHTML; + }); + + it('should return a valid HTML document', () => { + const html = renderProductionHTML(500); + + expect(html).toContain(''); + expect(html).toContain(''); + }); + + it('should show the status code prominently', () => { + const html = renderProductionHTML(500); + + expect(html).toContain('500'); + }); + + it('should show default title for 500 errors', () => { + const html = renderProductionHTML(500); + + expect(html).toContain('Server Error'); + }); + + it('should show default title for 404 errors', () => { + const html = renderProductionHTML(404); + + expect(html).toContain('Not Found'); + }); + + it('should accept custom title and message', () => { + const html = renderProductionHTML(503, { + title: 'Service Unavailable', + message: 'We are currently undergoing maintenance.', + }); + + expect(html).toContain('Service Unavailable'); + expect(html).toContain('We are currently undergoing maintenance.'); + }); + + it('should not contain stack traces or internal details', () => { + const html = renderProductionHTML(500); + + expect(html).not.toContain('at '); + expect(html).not.toContain('node_modules'); + expect(html).not.toContain('Stack Trace'); + }); + + it('should include a "Go Home" link', () => { + const html = renderProductionHTML(500); + + expect(html).toContain('Go Home'); + expect(html).toContain('href="/"'); + }); + + it('should support CSP nonce', () => { + const html = renderProductionHTML(500, { cspNonce: 'prod-nonce' }); + + expect(html).toContain('nonce="prod-nonce"'); + }); + + it('should escape HTML in custom messages', () => { + const html = renderProductionHTML(500, { message: '' }); + + expect(html).not.toContain(''); + expect(html).toContain('<script>'); + }); + + it('should support dark mode via prefers-color-scheme', () => { + const html = renderProductionHTML(500); + + expect(html).toContain('prefers-color-scheme: dark'); + }); +}); diff --git a/packages/error-page/src/index.ts b/packages/error-page/src/index.ts new file mode 100644 index 000000000..09c11b0ba --- /dev/null +++ b/packages/error-page/src/index.ts @@ -0,0 +1,151 @@ +import type { MetadataGroup, MetadataSection, ErrorPageHTMLOptions, ParsedError, StackFrame } from './types.js'; +import { parseError } from './parser.js'; +import { renderHTML, renderProductionHTML } from './renderer.js'; + +/** + * ErrorPage — Pretty print JavaScript errors as self-contained HTML pages. + * + * Edge-runtime compatible (no Node.js `fs`). + * + * @example + * ```ts + * import { ErrorPage } from '@ottabase/error-page'; + * + * const errorPage = new ErrorPage(); + * + * // Add request metadata + * errorPage.group('Request', { + * headers: [ + * { key: 'host', value: request.headers.get('host') }, + * { key: 'user-agent', value: request.headers.get('user-agent') }, + * ], + * }); + * + * // Render error to HTML + * const html = errorPage.toHTML(error); + * return new Response(html, { + * status: 500, + * headers: { 'Content-Type': 'text/html' }, + * }); + * ``` + */ +export class ErrorPage { + #metadata: MetadataGroup[] = []; + + /** + * Add a metadata group (e.g., "Request", "Environment"). + * Calling with the same group name merges sections. + * + * @param name - Group name + * @param sections - Record of section name → array of key/value rows + */ + group(name: string, sections: Record): this { + const existing = this.#metadata.find((g) => g.name === name); + if (existing) { + Object.assign(existing.sections, sections); + } else { + this.#metadata.push({ name, sections }); + } + return this; + } + + /** + * Add request metadata from a standard Request object. + * Extracts method, URL, and common headers. + */ + addRequestMetadata(request: Request): this { + const url = new URL(request.url); + const headerRows: { key: string; value: unknown }[] = []; + + // Collect important headers + const importantHeaders = [ + 'host', + 'user-agent', + 'accept', + 'content-type', + 'authorization', + 'cookie', + 'referer', + 'x-forwarded-for', + 'x-real-ip', + 'cf-connecting-ip', + 'cf-ray', + ]; + + for (const name of importantHeaders) { + const value = request.headers.get(name); + if (value) { + // Mask sensitive headers + const masked = name === 'authorization' || name === 'cookie' ? maskValue(value) : value; + headerRows.push({ key: name, value: masked }); + } + } + + return this.group('Request', { + info: [ + { key: 'Method', value: request.method }, + { key: 'URL', value: request.url }, + { key: 'Pathname', value: url.pathname }, + ...(url.search ? [{ key: 'Query', value: url.search }] : []), + ], + ...(headerRows.length > 0 ? { headers: headerRows } : {}), + }); + } + + /** + * Parse an error into a structured ParsedError object. + * + * @param error - The error to parse (Error instance, string, or any thrown value) + * @param offset - Number of stack frames to skip + */ + parse(error: unknown, offset: number = 0): ParsedError { + return parseError(error, offset); + } + + /** + * Render an error as a self-contained HTML page. + * + * @param error - The error to render + * @param options - HTML rendering options + * @returns Complete HTML document string + */ + toHTML(error: unknown, options?: ErrorPageHTMLOptions): string { + const parsed = parseError(error, options?.offset); + return renderHTML(parsed, this.#metadata, { + title: options?.title, + ide: options?.ide, + cspNonce: options?.cspNonce, + }); + } + + /** + * Render a minimal, production-safe error page. + * Does not expose stack traces, internal paths, or error details. + * + * @param status - HTTP status code + * @param options - Optional title, message, and CSP nonce + */ + toProductionHTML(status: number, options?: { title?: string; message?: string; cspNonce?: string }): string { + return renderProductionHTML(status, options); + } +} + +/** + * Mask a sensitive string, showing only the first and last 4 characters. + */ +function maskValue(value: string): string { + if (value.length <= 8) return '****'; + return value.slice(0, 4) + '****' + value.slice(-4); +} + +// ─── Re-exports ────────────────────────────────────────────────────────── +export { parseError } from './parser.js'; +export { renderHTML, renderProductionHTML } from './renderer.js'; +export type { + ParsedError, + StackFrame, + MetadataGroup, + MetadataSection, + MetadataRow, + ErrorPageHTMLOptions, +} from './types.js'; diff --git a/packages/error-page/src/parser.ts b/packages/error-page/src/parser.ts new file mode 100644 index 000000000..7583cf603 --- /dev/null +++ b/packages/error-page/src/parser.ts @@ -0,0 +1,164 @@ +import type { StackFrame, ParsedError } from './types.js'; + +/** + * Regex patterns for parsing stack trace lines. + * Handles V8 (Chrome/Node/Workers), SpiderMonkey (Firefox), and JavaScriptCore (Safari). + */ +const V8_FRAME_RE = /^\s*at\s+(?:(.+?)\s+\()?(.+?):(\d+):(\d+)\)?$/; +const V8_NATIVE_RE = /^\s*at\s+(.+?)\s+\(\)$/; +const V8_EVAL_RE = /^\s*at\s+(?:(.+?)\s+\()?eval\s+at\s+.+?,\s*(.+?):(\d+):(\d+)\)?$/; + +/** + * Parse a single stack trace line into a StackFrame. + */ +function parseFrame(line: string): StackFrame | null { + const trimmed = line.trim(); + if (!trimmed || trimmed === 'Error' || !trimmed.startsWith('at ')) { + // Try non-V8 format (Firefox/Safari): "functionName@file:line:col" + const atIdx = trimmed.indexOf('@'); + if (atIdx > -1) { + const fnName = trimmed.slice(0, atIdx); + const rest = trimmed.slice(atIdx + 1); + const match = rest.match(/^(.+?):(\d+):(\d+)$/); + if (match) { + const file = match[1]; + return { + raw: line, + file, + line: parseInt(match[2], 10), + column: parseInt(match[3], 10), + function: fnName || undefined, + isApp: isAppFrame(file), + }; + } + } + if (!trimmed.startsWith('at ')) return null; + } + + // Try eval frame + let match = trimmed.match(V8_EVAL_RE); + if (match) { + return { + raw: line, + file: match[2], + line: parseInt(match[3], 10), + column: parseInt(match[4], 10), + function: match[1] || 'eval', + isApp: isAppFrame(match[2]), + }; + } + + // Try native/anonymous frame + match = trimmed.match(V8_NATIVE_RE); + if (match) { + return { + raw: line, + function: match[1], + isApp: false, + }; + } + + // Try standard V8 frame + match = trimmed.match(V8_FRAME_RE); + if (match) { + const file = match[2]; + return { + raw: line, + file, + line: parseInt(match[3], 10), + column: parseInt(match[4], 10), + function: match[1] || undefined, + isApp: isAppFrame(file), + }; + } + + // Fallback: plain "at something" line + return { + raw: line, + function: trimmed.replace(/^\s*at\s+/, ''), + isApp: false, + }; +} + +/** + * Determine whether a file path is application code (not node_modules or internal). + */ +function isAppFrame(file: string): boolean { + if (!file) return false; + if (file.includes('node_modules')) return false; + if (file.startsWith('node:') || file.startsWith('internal/')) return false; + if (file === '' || file.includes('wrangler')) return false; + return true; +} + +/** + * Safely extract extra properties from an error object (beyond the standard fields). + */ +function extractProperties(error: unknown): Record { + if (!(error instanceof Error)) { + return typeof error === 'object' && error !== null ? { ...(error as object) } : {}; + } + + const props: Record = {}; + const skip = new Set(['name', 'message', 'stack', 'cause']); + + for (const key of Object.getOwnPropertyNames(error)) { + if (!skip.has(key)) { + try { + props[key] = (error as unknown as Record)[key]; + } catch { + // skip non-readable properties + } + } + } + + return props; +} + +/** + * Parse an error (or unknown thrown value) into a structured ParsedError. + * + * @param error - The error to parse + * @param offset - Number of frames to skip from the top of the stack + */ +export function parseError(error: unknown, offset: number = 0): ParsedError { + // Handle non-Error values + if (!(error instanceof Error)) { + return { + type: typeof error === 'object' && error !== null ? error.constructor?.name || 'Object' : typeof error, + message: String(error), + frames: [], + properties: extractProperties(error), + }; + } + + const type = error.constructor?.name || 'Error'; + const message = error.message || ''; + const rawStack = error.stack || ''; + + // Parse stack frames + const lines = rawStack.split('\n'); + const frames: StackFrame[] = []; + for (const line of lines) { + const frame = parseFrame(line); + if (frame) { + frames.push(frame); + } + } + + // Apply offset + const offsetFrames = offset > 0 ? frames.slice(offset) : frames; + + // Parse cause recursively + const errorCause = (error as unknown as Record).cause; + const cause = errorCause ? parseError(errorCause) : undefined; + + return { + type, + message, + frames: offsetFrames, + rawStack, + cause, + properties: extractProperties(error), + }; +} diff --git a/packages/error-page/src/renderer.ts b/packages/error-page/src/renderer.ts new file mode 100644 index 000000000..33cac3536 --- /dev/null +++ b/packages/error-page/src/renderer.ts @@ -0,0 +1,762 @@ +import type { ParsedError, MetadataGroup, StackFrame } from './types.js'; + +// ─── IDE URL Templates ──────────────────────────────────────────────────── +const IDE_URLS: Record = { + vscode: 'vscode://file/%f:%l:%c', + textmate: 'txmt://open?url=file://%f&line=%l&column=%c', + sublime: 'subl://open?url=file://%f&line=%l&column=%c', + phpstorm: 'phpstorm://open?file=%f&line=%l&column=%c', + atom: 'atom://core/open/file?filename=%f&line=%l&column=%c', + emacs: 'emacs://open?url=file://%f&line=%l&column=%c', + macvim: 'mvim://open?url=file://%f&line=%l&column=%c', +}; + +function editorUrl(ide: string, file: string, line?: number, column?: number): string { + const template = IDE_URLS[ide] || ide; + return template + .replace(/%f/g, encodeURIComponent(file)) + .replace(/%l/g, String(line ?? 1)) + .replace(/%c/g, String(column ?? 1)); +} + +// ─── HTML Escaping ──────────────────────────────────────────────────────── +function esc(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} + +// ─── JSON Serializer (safe, handles circular refs) ──────────────────────── +function safeStringify(value: unknown, indent: number = 2): string { + const seen = new WeakSet(); + return JSON.stringify( + value, + (_key, val) => { + if (typeof val === 'object' && val !== null) { + if (seen.has(val)) return '[Circular]'; + seen.add(val); + } + if (typeof val === 'bigint') return val.toString(); + if (typeof val === 'function') return `[Function: ${val.name || 'anonymous'}]`; + if (typeof val === 'symbol') return val.toString(); + if (val === undefined) return '[undefined]'; + return val; + }, + indent, + ); +} + +// ─── CSS ────────────────────────────────────────────────────────────────── +function getStyles(): string { + return ` + :root { + --ep-bg: #fafafa; + --ep-fg: #1a1a1a; + --ep-muted: #6b7280; + --ep-border: #e5e7eb; + --ep-accent: #dc2626; + --ep-accent-bg: #fef2f2; + --ep-app-bg: #eff6ff; + --ep-app-border: #3b82f6; + --ep-frame-bg: #ffffff; + --ep-frame-hover: #f9fafb; + --ep-code-bg: #f3f4f6; + --ep-code-fg: #374151; + --ep-badge-bg: #e5e7eb; + --ep-badge-fg: #374151; + --ep-cause-bg: #fffbeb; + --ep-cause-border: #f59e0b; + --ep-meta-bg: #f0fdf4; + --ep-meta-border: #22c55e; + --ep-link: #2563eb; + --ep-raw-bg: #f8fafc; + --ep-tab-active: #2563eb; + --ep-tab-inactive: #9ca3af; + } + html.dark { + --ep-bg: #0f0f0f; + --ep-fg: #e5e5e5; + --ep-muted: #9ca3af; + --ep-border: #2d2d2d; + --ep-accent: #ef4444; + --ep-accent-bg: #1c0d0d; + --ep-app-bg: #0c1929; + --ep-app-border: #3b82f6; + --ep-frame-bg: #161616; + --ep-frame-hover: #1e1e1e; + --ep-code-bg: #1e1e1e; + --ep-code-fg: #d1d5db; + --ep-badge-bg: #2d2d2d; + --ep-badge-fg: #d1d5db; + --ep-cause-bg: #1a1400; + --ep-cause-border: #d97706; + --ep-meta-bg: #0a1a0a; + --ep-meta-border: #16a34a; + --ep-link: #60a5fa; + --ep-raw-bg: #111111; + --ep-tab-active: #60a5fa; + --ep-tab-inactive: #6b7280; + } + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--ep-bg); + color: var(--ep-fg); + line-height: 1.6; + -webkit-font-smoothing: antialiased; + } + .ep-container { + max-width: 960px; + margin: 0 auto; + padding: 32px 24px; + } + /* ─── Header ───────────────────────────────── */ + .ep-header { + display: flex; + align-items: center; + justify-content: space-between; + margin-bottom: 24px; + } + .ep-logo { + font-size: 13px; + font-weight: 600; + color: var(--ep-muted); + letter-spacing: 0.5px; + text-transform: uppercase; + } + .ep-theme-toggle { + background: var(--ep-badge-bg); + border: 1px solid var(--ep-border); + color: var(--ep-fg); + border-radius: 6px; + padding: 4px 10px; + font-size: 12px; + cursor: pointer; + transition: background 0.15s; + } + .ep-theme-toggle:hover { opacity: 0.8; } + /* ─── Error Info ───────────────────────────── */ + .ep-error-info { + background: var(--ep-accent-bg); + border: 1px solid var(--ep-accent); + border-radius: 8px; + padding: 20px 24px; + margin-bottom: 24px; + } + .ep-error-type { + display: inline-block; + background: var(--ep-accent); + color: #fff; + font-size: 12px; + font-weight: 600; + padding: 2px 8px; + border-radius: 4px; + margin-bottom: 8px; + } + .ep-error-message { + font-size: 18px; + font-weight: 600; + color: var(--ep-accent); + word-break: break-word; + } + .ep-error-title { + font-size: 13px; + color: var(--ep-muted); + margin-top: 4px; + } + /* ─── Properties ───────────────────────────── */ + .ep-props { + margin-top: 12px; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + .ep-prop-badge { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 12px; + background: var(--ep-badge-bg); + color: var(--ep-badge-fg); + padding: 2px 8px; + border-radius: 4px; + font-family: 'SF Mono', Monaco, Consolas, monospace; + } + .ep-prop-key { font-weight: 600; } + /* ─── Tabs ─────────────────────────────────── */ + .ep-tabs { + display: flex; + gap: 0; + border-bottom: 1px solid var(--ep-border); + margin-bottom: 16px; + } + .ep-tab { + padding: 8px 16px; + font-size: 13px; + font-weight: 500; + color: var(--ep-tab-inactive); + cursor: pointer; + border-bottom: 2px solid transparent; + background: none; + border-top: none; + border-left: none; + border-right: none; + transition: color 0.15s, border-color 0.15s; + } + .ep-tab:hover { color: var(--ep-fg); } + .ep-tab.active { + color: var(--ep-tab-active); + border-bottom-color: var(--ep-tab-active); + } + .ep-tab-panel { display: none; } + .ep-tab-panel.active { display: block; } + /* ─── Stack Frames ─────────────────────────── */ + .ep-frames { display: flex; flex-direction: column; gap: 2px; } + .ep-frame { + border: 1px solid var(--ep-border); + border-radius: 6px; + overflow: hidden; + transition: border-color 0.15s; + } + .ep-frame.is-app { + border-left: 3px solid var(--ep-app-border); + } + .ep-frame-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: var(--ep-frame-bg); + cursor: pointer; + user-select: none; + transition: background 0.15s; + font-size: 13px; + } + .ep-frame-header:hover { background: var(--ep-frame-hover); } + .ep-frame-chevron { + width: 16px; + height: 16px; + flex-shrink: 0; + transition: transform 0.15s; + color: var(--ep-muted); + } + .ep-frame.open .ep-frame-chevron { transform: rotate(90deg); } + .ep-frame-fn { + font-weight: 600; + color: var(--ep-fg); + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 12.5px; + } + .ep-frame-file { + color: var(--ep-muted); + font-size: 12px; + margin-left: auto; + text-align: right; + flex-shrink: 0; + } + .ep-frame-file a { + color: var(--ep-link); + text-decoration: none; + } + .ep-frame-file a:hover { text-decoration: underline; } + .ep-frame-body { + display: none; + padding: 0; + background: var(--ep-code-bg); + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 12px; + overflow-x: auto; + border-top: 1px solid var(--ep-border); + } + .ep-frame.open .ep-frame-body { display: block; } + .ep-frame-raw { + padding: 12px 16px; + color: var(--ep-code-fg); + white-space: pre-wrap; + word-break: break-all; + } + .ep-app-badge { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + background: var(--ep-app-border); + color: #fff; + padding: 1px 5px; + border-radius: 3px; + flex-shrink: 0; + } + /* ─── Raw Output ───────────────────────────── */ + .ep-raw { + background: var(--ep-raw-bg); + border: 1px solid var(--ep-border); + border-radius: 6px; + padding: 16px; + overflow-x: auto; + } + .ep-raw pre { + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 12px; + line-height: 1.5; + color: var(--ep-code-fg); + white-space: pre-wrap; + word-break: break-word; + } + /* ─── Error Cause ──────────────────────────── */ + .ep-cause { + background: var(--ep-cause-bg); + border: 1px solid var(--ep-cause-border); + border-radius: 8px; + padding: 16px 20px; + margin-top: 24px; + } + .ep-cause-title { + font-size: 13px; + font-weight: 600; + color: var(--ep-cause-border); + margin-bottom: 8px; + } + .ep-cause-message { + font-size: 14px; + font-weight: 500; + margin-bottom: 8px; + } + .ep-cause-type { + display: inline-block; + background: var(--ep-cause-border); + color: #fff; + font-size: 11px; + font-weight: 600; + padding: 1px 6px; + border-radius: 3px; + margin-right: 6px; + } + .ep-cause-frames { + margin-top: 8px; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 11px; + color: var(--ep-muted); + max-height: 150px; + overflow-y: auto; + } + .ep-cause-frames div { padding: 1px 0; } + /* ─── Metadata ─────────────────────────────── */ + .ep-metadata { + margin-top: 24px; + } + .ep-meta-group { + background: var(--ep-meta-bg); + border: 1px solid var(--ep-meta-border); + border-radius: 8px; + margin-bottom: 12px; + overflow: hidden; + } + .ep-meta-group-title { + font-size: 13px; + font-weight: 600; + color: var(--ep-meta-border); + padding: 10px 16px; + border-bottom: 1px solid var(--ep-meta-border); + cursor: pointer; + user-select: none; + } + .ep-meta-group-title:hover { opacity: 0.8; } + .ep-meta-section { + padding: 8px 16px; + } + .ep-meta-section-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + color: var(--ep-muted); + letter-spacing: 0.5px; + padding: 4px 0; + } + .ep-meta-row { + display: flex; + gap: 12px; + padding: 3px 0; + font-size: 12.5px; + border-bottom: 1px solid var(--ep-border); + } + .ep-meta-row:last-child { border-bottom: none; } + .ep-meta-key { + font-weight: 600; + min-width: 120px; + flex-shrink: 0; + color: var(--ep-muted); + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 11.5px; + } + .ep-meta-value { + color: var(--ep-fg); + word-break: break-all; + font-family: 'SF Mono', Monaco, Consolas, monospace; + font-size: 11.5px; + } + /* ─── Footer ───────────────────────────────── */ + .ep-footer { + margin-top: 32px; + padding-top: 16px; + border-top: 1px solid var(--ep-border); + font-size: 12px; + color: var(--ep-muted); + text-align: center; + } + .ep-timestamp { + font-size: 12px; + color: var(--ep-muted); + font-family: 'SF Mono', Monaco, Consolas, monospace; + } + /* ─── Copy Button ──────────────────────────── */ + .ep-copy-btn { + background: var(--ep-badge-bg); + border: 1px solid var(--ep-border); + color: var(--ep-fg); + border-radius: 6px; + padding: 4px 12px; + font-size: 12px; + cursor: pointer; + transition: background 0.15s, opacity 0.15s; + margin-bottom: 8px; + display: inline-flex; + align-items: center; + gap: 4px; + } + .ep-copy-btn:hover { opacity: 0.8; } + .ep-copy-btn.copied { background: var(--ep-meta-border); color: #fff; border-color: var(--ep-meta-border); } + `; +} + +// ─── JavaScript ─────────────────────────────────────────────────────────── +function getScript(): string { + return ` + (function() { + // Theme toggle + var html = document.documentElement; + var toggle = document.getElementById('ep-theme-toggle'); + var prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + if (prefersDark) html.classList.add('dark'); + if (toggle) { + toggle.addEventListener('click', function() { + html.classList.toggle('dark'); + toggle.textContent = html.classList.contains('dark') ? '☀ Light' : '● Dark'; + }); + toggle.textContent = html.classList.contains('dark') ? '☀ Light' : '● Dark'; + } + + // Frame accordion + document.querySelectorAll('.ep-frame-header').forEach(function(header) { + header.addEventListener('click', function() { + var frame = header.parentElement; + frame.classList.toggle('open'); + }); + }); + + // Tabs + document.querySelectorAll('.ep-tab').forEach(function(tab) { + tab.addEventListener('click', function() { + var target = tab.getAttribute('data-tab'); + var container = tab.closest('.ep-container'); + container.querySelectorAll('.ep-tab').forEach(function(t) { t.classList.remove('active'); }); + container.querySelectorAll('.ep-tab-panel').forEach(function(p) { p.classList.remove('active'); }); + tab.classList.add('active'); + var panel = container.querySelector('[data-panel="' + target + '"]'); + if (panel) panel.classList.add('active'); + }); + }); + + // Auto-open first app frame + var firstAppFrame = document.querySelector('.ep-frame.is-app'); + if (firstAppFrame) firstAppFrame.classList.add('open'); + + // Copy-to-clipboard for raw view + var copyBtn = document.getElementById('ep-copy-raw'); + if (copyBtn) { + copyBtn.addEventListener('click', function() { + var raw = document.querySelector('.ep-raw pre'); + if (raw && navigator.clipboard) { + navigator.clipboard.writeText(raw.textContent || '').then(function() { + copyBtn.textContent = '✓ Copied'; + copyBtn.classList.add('copied'); + setTimeout(function() { + copyBtn.textContent = '📋 Copy'; + copyBtn.classList.remove('copied'); + }, 2000); + }); + } + }); + } + })(); + `; +} + +// ─── Chevron SVG ────────────────────────────────────────────────────────── +const CHEVRON_SVG = ``; + +// ─── Frame Rendering ────────────────────────────────────────────────────── +function renderFrame(frame: StackFrame, ide: string): string { + const fnName = esc(frame.function || '(anonymous)'); + const appClass = frame.isApp ? ' is-app' : ''; + const appBadge = frame.isApp ? `app` : ''; + + let fileInfo = ''; + if (frame.file) { + const loc = `${esc(frame.file)}:${frame.line ?? '?'}:${frame.column ?? '?'}`; + const url = editorUrl(ide, frame.file, frame.line, frame.column); + fileInfo = `${loc}`; + } + + const rawContent = esc(frame.raw.trim()); + + return ` +
+
+ ${CHEVRON_SVG} + ${appBadge} + ${fnName} + ${fileInfo} +
+
+
${rawContent}
+
+
`; +} + +// ─── Cause Chain Rendering ──────────────────────────────────────────────── +function renderCause(cause: ParsedError): string { + let html = ` +
+
⚡ Error Cause
+
+ ${esc(cause.type)} + ${esc(cause.message)} +
`; + + if (cause.frames.length > 0) { + html += `
`; + for (const f of cause.frames.slice(0, 10)) { + html += `
${esc(f.raw.trim())}
`; + } + if (cause.frames.length > 10) { + html += `
... ${cause.frames.length - 10} more frames
`; + } + html += `
`; + } + + // Recursive cause + if (cause.cause) { + html += renderCause(cause.cause); + } + + html += `
`; + return html; +} + +// ─── Properties Rendering ───────────────────────────────────────────────── +function renderProperties(properties: Record): string { + const keys = Object.keys(properties); + if (keys.length === 0) return ''; + + let html = `
`; + for (const key of keys) { + const val = properties[key]; + const display = typeof val === 'object' ? JSON.stringify(val) : String(val); + html += `${esc(key)}: ${esc(display)}`; + } + html += `
`; + return html; +} + +// ─── Metadata Rendering ────────────────────────────────────────────────── +function renderMetadata(groups: MetadataGroup[]): string { + if (groups.length === 0) return ''; + + let html = ``; + return html; +} + +// ─── Main Renderer ──────────────────────────────────────────────────────── + +/** + * Render a ParsedError and metadata groups into a self-contained HTML page. + * + * @param error - The parsed error to render + * @param metadata - Optional metadata groups to display + * @param options - Rendering options (title, ide, cspNonce) + */ +export function renderHTML( + error: ParsedError, + metadata: MetadataGroup[] = [], + options: { + title?: string; + ide?: string; + cspNonce?: string; + } = {}, +): string { + const title = options.title ?? 'An error has occurred'; + const ide = options.ide ?? 'vscode'; + const nonce = options.cspNonce ? ` nonce="${esc(options.cspNonce)}"` : ''; + + // Error info section + const errorInfo = ` +
+ ${esc(error.type)} +
${esc(error.message)}
+
${esc(title)}
+ ${renderProperties(error.properties)} +
`; + + // Stack tab + const stackFrames = error.frames.map((f) => renderFrame(f, ide)).join(''); + const stackTab = `
${stackFrames || '

No stack frames available.

'}
`; + + // Raw tab + const rawContent = safeStringify( + { + type: error.type, + message: error.message, + properties: error.properties, + stack: error.rawStack, + }, + 2, + ); + const rawTab = `
${esc(rawContent)}
`; + + // Cause section + const causeSection = error.cause ? renderCause(error.cause) : ''; + + // Metadata section + const metadataSection = renderMetadata(metadata); + + const timestamp = new Date().toISOString(); + + return ` + + + + + ${esc(error.type)}: ${esc(error.message)} — ${esc(title)} + ${getStyles()} + + +
+
+ + ${esc(timestamp)} + +
+ + ${errorInfo} + +
+ + +
+
${stackTab}
+
${rawTab}
+ + ${causeSection} + ${metadataSection} + + +
+${getScript()} + +`; +} + +// ─── Production Error Page ──────────────────────────────────────────────── + +/** + * Render a minimal, safe error page for production environments. + * Does not expose stack traces, internal paths, or error details. + * + * @param status - HTTP status code + * @param options - Optional title, message, and CSP nonce + */ +export function renderProductionHTML( + status: number, + options: { + title?: string; + message?: string; + cspNonce?: string; + } = {}, +): string { + const statusTitle = status >= 500 ? 'Server Error' : status === 404 ? 'Not Found' : 'Error'; + const title = options.title ?? statusTitle; + const message = options.message ?? 'Something went wrong. Please try again later.'; + const nonce = options.cspNonce ? ` nonce="${esc(options.cspNonce)}"` : ''; + + return ` + + + + + ${status} — ${esc(title)} + + :root { + --ep-bg: #fafafa; --ep-fg: #1a1a1a; --ep-muted: #6b7280; + --ep-border: #e5e7eb; --ep-accent: #dc2626; --ep-accent-bg: #fef2f2; + } + @media (prefers-color-scheme: dark) { + :root { + --ep-bg: #0f0f0f; --ep-fg: #e5e5e5; --ep-muted: #9ca3af; + --ep-border: #2d2d2d; --ep-accent: #ef4444; --ep-accent-bg: #1c0d0d; + } + } + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--ep-bg); color: var(--ep-fg); line-height: 1.6; + display: flex; align-items: center; justify-content: center; min-height: 100vh; + -webkit-font-smoothing: antialiased; + } + .ep-prod { text-align: center; max-width: 480px; padding: 48px 24px; } + .ep-prod-status { + font-size: 72px; font-weight: 700; color: var(--ep-accent); + line-height: 1; margin-bottom: 8px; + } + .ep-prod-title { + font-size: 20px; font-weight: 600; margin-bottom: 12px; color: var(--ep-fg); + } + .ep-prod-message { + font-size: 14px; color: var(--ep-muted); margin-bottom: 24px; line-height: 1.5; + } + .ep-prod-action { + display: inline-block; padding: 8px 20px; font-size: 13px; font-weight: 500; + color: var(--ep-fg); border: 1px solid var(--ep-border); border-radius: 6px; + text-decoration: none; transition: background 0.15s; + } + .ep-prod-action:hover { background: var(--ep-accent-bg); } + + + +
+
${status}
+
${esc(title)}
+
${esc(message)}
+ Go Home +
+ +`; +} diff --git a/packages/error-page/src/types.ts b/packages/error-page/src/types.ts new file mode 100644 index 000000000..417e3e13b --- /dev/null +++ b/packages/error-page/src/types.ts @@ -0,0 +1,62 @@ +/** + * Types for the @ottabase/error-page renderer. + */ + +/** A single parsed stack frame */ +export interface StackFrame { + /** The raw line from the stack trace */ + raw: string; + /** File path (if parsed) */ + file?: string; + /** Line number (if parsed) */ + line?: number; + /** Column number (if parsed) */ + column?: number; + /** Function/method name (if parsed) */ + function?: string; + /** Whether this frame is from application code (not node_modules/internal) */ + isApp: boolean; +} + +/** Structured representation of a parsed error */ +export interface ParsedError { + /** Error class/constructor name (e.g., "TypeError", "ServiceError") */ + type: string; + /** Error message */ + message: string; + /** Parsed stack frames */ + frames: StackFrame[]; + /** Raw stack trace string */ + rawStack?: string; + /** Error cause (chained error) */ + cause?: ParsedError; + /** Additional properties from the error object */ + properties: Record; +} + +/** A metadata key-value row */ +export interface MetadataRow { + key: string; + value: unknown; +} + +/** A section within a metadata group */ +export type MetadataSection = MetadataRow[]; + +/** A named group of metadata sections */ +export interface MetadataGroup { + name: string; + sections: Record; +} + +/** Options for HTML rendering */ +export interface ErrorPageHTMLOptions { + /** Page title (default: "An error has occurred") */ + title?: string; + /** Number of stack frames to skip from the top */ + offset?: number; + /** Code editor for "open in editor" links (default: "vscode") */ + ide?: string; + /** CSP nonce for inline style/script tags */ + cspNonce?: string; +} diff --git a/packages/error-page/tsconfig.json b/packages/error-page/tsconfig.json new file mode 100644 index 000000000..f7bf841b1 --- /dev/null +++ b/packages/error-page/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/__tests__/**"] +} diff --git a/packages/error-page/tsup.config.ts b/packages/error-page/tsup.config.ts new file mode 100644 index 000000000..e0e2d2a2e --- /dev/null +++ b/packages/error-page/tsup.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from 'tsup'; + +export default defineConfig({ + entry: ['src/index.ts'], + format: ['cjs', 'esm'], + dts: true, + clean: true, +}); diff --git a/packages/error-page/vitest.config.ts b/packages/error-page/vitest.config.ts new file mode 100644 index 000000000..8d4ad08ba --- /dev/null +++ b/packages/error-page/vitest.config.ts @@ -0,0 +1,18 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + test: { + globals: true, + environment: 'node', + coverage: { + provider: 'v8', + reporter: ['text', 'json', 'html'], + thresholds: { + lines: 70, + functions: 70, + branches: 65, + statements: 70, + }, + }, + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97de99fc1..9b41b8230 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -516,6 +516,9 @@ importers: '@ottabase/email': specifier: workspace:* version: link:../../packages/email + '@ottabase/error-page': + specifier: workspace:* + version: link:../../packages/error-page '@ottabase/forms': specifier: workspace:* version: link:../../packages/forms @@ -1105,6 +1108,27 @@ importers: specifier: 'catalog:' version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.28)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/error-page: + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 20.19.28 + eslint: + specifier: 'catalog:' + version: 9.39.2(jiti@2.6.1) + rimraf: + specifier: 'catalog:' + version: 6.1.2 + tsup: + specifier: 'catalog:' + version: 8.5.1(@swc/core@1.13.5)(jiti@2.6.1)(postcss@8.5.6)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.2) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.0.18(@opentelemetry/api@1.9.0)(@types/node@20.19.28)(@vitest/ui@4.0.18)(happy-dom@20.4.0)(jiti@2.6.1)(jsdom@27.4.0(@noble/hashes@1.8.0))(lightningcss@1.30.2)(sugarss@5.0.1(postcss@8.5.6))(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/forms: dependencies: '@ottabase/ottaselect': @@ -8685,7 +8709,7 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Glob versions prior to v9 are no longer supported + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me glob@9.3.5: resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} @@ -18968,7 +18992,7 @@ snapshots: eslint: 9.39.2(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.2(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.2(jiti@2.6.1)) @@ -19001,7 +19025,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -19016,7 +19040,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.2(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.52.0(eslint@9.39.2(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)))(eslint@9.39.2(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9