diff --git a/packages/void/src/assets.d.ts b/packages/void/src/assets.d.ts new file mode 100644 index 000000000..a2cb5c3a0 --- /dev/null +++ b/packages/void/src/assets.d.ts @@ -0,0 +1,14 @@ +declare module '*.css?raw' { + const content: string; + export default content; +} + +declare module '*.html?raw' { + const content: string; + export default content; +} + +declare module '*.js?raw' { + const content: string; + export default content; +} diff --git a/packages/void/src/css.d.ts b/packages/void/src/css.d.ts deleted file mode 100644 index 4f8dea640..000000000 --- a/packages/void/src/css.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare module '*.css?raw' { - const content: string; - export default content; -} diff --git a/packages/void/src/runtime/error-pages/404.html b/packages/void/src/runtime/error-pages/404.html new file mode 100644 index 000000000..835a02779 --- /dev/null +++ b/packages/void/src/runtime/error-pages/404.html @@ -0,0 +1,50 @@ + + + + + + 404: Not Found + + + + + + + + + + +
+ + + + +
+ + + + +

+ 404Lost in the void +

+

This page doesn't exist

+ +
+ + + + + +
+ + diff --git a/packages/void/src/runtime/error-pages/500.html b/packages/void/src/runtime/error-pages/500.html new file mode 100644 index 000000000..690e29849 --- /dev/null +++ b/packages/void/src/runtime/error-pages/500.html @@ -0,0 +1,50 @@ + + + + + + 500: Internal Server Error + + + + + + + + + + +
+ + + + +
+ + + + +

+ 500Something went wrong +

+

Please try again in a moment

+ Reload page +
+ + + + + +
+ + diff --git a/packages/void/src/runtime/error-pages/index.ts b/packages/void/src/runtime/error-pages/index.ts new file mode 100644 index 000000000..0765607cd --- /dev/null +++ b/packages/void/src/runtime/error-pages/index.ts @@ -0,0 +1,14 @@ +import notFoundPage from './404.html?raw'; +import serverErrorPage from './500.html?raw'; +import { inlineErrorPageAssets } from './inline.ts'; +import script from './script.js?raw'; +import stylesheet from './style.css?raw'; + +const pages = { + 404: inlineErrorPageAssets(notFoundPage, stylesheet, script), + 500: inlineErrorPageAssets(serverErrorPage, stylesheet, script), +}; + +export function renderDefaultErrorPage(status: 404 | 500): string { + return pages[status]; +} diff --git a/packages/void/src/runtime/error-pages/inline.ts b/packages/void/src/runtime/error-pages/inline.ts new file mode 100644 index 000000000..92df8fb27 --- /dev/null +++ b/packages/void/src/runtime/error-pages/inline.ts @@ -0,0 +1,24 @@ +const closing = ''; + +function replaceAssetBlock(html: string, name: 'css' | 'js', content: string): string { + const opening = ``; + const start = html.indexOf(opening); + const end = html.indexOf(closing, start + opening.length); + if (start === -1 || end === -1 || html.lastIndexOf(opening) !== start) { + throw new Error(`Expected one complete ${name} block in the default error page.`); + } + return html.slice(0, start) + content + html.slice(end + closing.length); +} + +export function inlineErrorPageAssets(html: string, stylesheet: string, script: string): string { + if (html.split(closing).length !== 3) { + throw new Error('Expected two endinject markers in the default error page.'); + } + for (const [name, content] of [ + ['css', ``], + ['js', ``], + ] as const) { + html = replaceAssetBlock(html, name, content); + } + return html; +} diff --git a/packages/void/src/runtime/error-pages/script.js b/packages/void/src/runtime/error-pages/script.js new file mode 100644 index 000000000..6d1a39fc3 --- /dev/null +++ b/packages/void/src/runtime/error-pages/script.js @@ -0,0 +1,79 @@ +(() => { + const designs = ['signal', 'grid', 'fold']; + document.documentElement.dataset.design = designs[Math.floor(Math.random() * designs.length)]; + + function setup() { + const back = document.getElementById('void-error-back'); + const canGoBack = window.navigation ? window.navigation.canGoBack : window.history.length > 1; + if (back && canGoBack) { + back.hidden = false; + back.addEventListener('click', () => window.history.back()); + } + + if (document.documentElement.dataset.design !== 'signal') { + return; + } + const canvas = document.querySelector('.error-texture'); + const ctx = canvas.getContext('2d'); + if (!ctx) { + return; + } + + function draw() { + const { width, height } = canvas.getBoundingClientRect(); + const ratio = Math.min(window.devicePixelRatio || 1, 2); + canvas.width = Math.round(width * ratio); + canvas.height = Math.round(height * ratio); + ctx.setTransform(ratio, 0, 0, ratio, 0, 0); + const colors = getComputedStyle(canvas); + const chars = ['·', '/', '0', '1', '+', '[', ']', '—']; + ctx.font = '8px ui-monospace, monospace'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = colors.color; + + for (let y = 8, row = 0; y < height; y += 12, row++) { + for (let x = 8, col = 0; x < width; x += 9, col++) { + const distance = Math.hypot( + (x - width / 2) / (width * 0.47), + (y - height * 0.48) / (height * 0.55), + ); + const seed = (Math.imul(row + 11, 61) ^ Math.imul(col + 7, 137)) >>> 0; + const core = Math.max(0, Math.min(1, (distance - 0.28) / 0.55)); + const opacity = core * (0.15 + (seed % 7) * 0.025) * 0.75; + if (seed % 17 === 0 || opacity < 0.015) { + continue; + } + ctx.globalAlpha = opacity; + ctx.fillText(chars[(seed >>> 3) % chars.length], x, y); + } + } + + ctx.globalAlpha = 1; + ctx.strokeStyle = colors.borderTopColor; + for (const [x, y] of [ + [28, 28], + [width - 28, 28], + [28, height - 28], + [width - 28, height - 28], + ]) { + ctx.beginPath(); + ctx.moveTo(x - 4, y); + ctx.lineTo(x + 4, y); + ctx.moveTo(x, y - 4); + ctx.lineTo(x, y + 4); + ctx.stroke(); + } + } + + new ResizeObserver(draw).observe(canvas); + window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', draw); + draw(); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', setup, { once: true }); + } else { + setup(); + } +})(); diff --git a/packages/void/src/runtime/error-pages/style.css b/packages/void/src/runtime/error-pages/style.css new file mode 100644 index 000000000..2602ba791 --- /dev/null +++ b/packages/void/src/runtime/error-pages/style.css @@ -0,0 +1,293 @@ +:root { + color-scheme: light dark; + --background: light-dark(#fff, #16171d); + --foreground: light-dark(#16171d, #f6f5f8); + --muted: light-dark(#6e6874, #a8a2ae); + --line: light-dark(#e5e4e7, #36323c); + --ink: light-dark(#4e4856, #c3bdcd); + --corner: light-dark(#e5e4e7, #3b3440); + background: var(--background); + color: var(--foreground); + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + -webkit-font-smoothing: antialiased; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; +} + +.error-page { + position: relative; + isolation: isolate; + display: flex; + align-items: center; + justify-content: center; + min-block-size: 100svh; + overflow: hidden; +} + +.error-content { + min-inline-size: 0; + padding: 32px 24px; + text-align: center; +} + +.error-content h1 { + display: flex; + flex-direction: column; + gap: 18px; + margin: 0; + font-weight: 500; +} + +.error-code { + font-size: 64px; + line-height: 1; + letter-spacing: -0.045em; +} + +.error-title { + font-size: 15px; + line-height: 1.4; + letter-spacing: -0.02em; + text-wrap: balance; +} + +.error-description { + margin: 6px 0 0; + color: var(--muted); + font-size: 12px; + line-height: 1.5; + letter-spacing: -0.01em; +} + +.error-action { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + min-block-size: 32px; + margin-block-start: 20px; + padding: 6px 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: var(--background); + color: var(--foreground); + font: + 500 12px/18px system-ui, + -apple-system, + BlinkMacSystemFont, + 'Segoe UI', + sans-serif; + letter-spacing: -0.01em; + text-decoration: none; + white-space: nowrap; + cursor: pointer; +} + +.error-action::after { + position: absolute; + inset: -6px -3px; + content: ''; +} + +.error-action:hover { + border-color: var(--foreground); + background: var(--foreground); + color: var(--background); +} + +.error-action:focus-visible { + outline: 2px solid var(--foreground); + outline-offset: 4px; +} + +.error-action[hidden] { + visibility: hidden; +} + +.error-arrow { + font-size: 15px; + line-height: 14px; +} + +.error-texture { + position: absolute; + z-index: -1; + inset: 0; + display: block; + inline-size: 100%; + block-size: 100%; + border-color: var(--line); + color: var(--ink); +} + +.grid-cell, +.grid-tick { + display: none; +} + +.grid-tick { + position: absolute; + z-index: 1; + inline-size: 0; + block-size: 0; + border: 5px solid transparent; + pointer-events: none; +} + +:root[data-design='grid'] .error-page { + display: grid; + grid-template-columns: minmax(24px, 1fr) minmax(0, 456px) minmax(24px, 1fr); + grid-template-rows: minmax(24px, 1fr) minmax(272px, auto) minmax(24px, 1fr); + align-items: stretch; +} + +:root[data-design='grid'] .grid-cell, +:root[data-design='grid'] .error-content { + min-inline-size: 0; + border-inline-end: 1px solid var(--line); + border-block-end: 1px solid var(--line); +} + +:root[data-design='grid'] .grid-cell { + display: block; +} + +:root[data-design='grid'] .grid-cell:nth-child(3n) { + border-inline-end: 0; +} + +:root[data-design='grid'] .grid-cell:nth-child(n + 7) { + border-block-end: 0; +} + +:root[data-design='grid'] .error-content { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 32px; +} + +:root[data-design='grid'] .grid-tick { + display: block; +} + +.grid-tick.left { + left: -1px; + border-left-color: var(--corner); +} + +.grid-tick.right { + right: 0; + border-right-color: var(--corner); +} + +.grid-tick.top { + top: -5px; +} + +.grid-tick.bottom { + bottom: -5px; +} + +:root[data-design='grid'] .error-texture, +:root[data-design='fold'] .error-texture { + display: none; +} + +:root[data-design='fold'] .error-page { + padding: 40px 42px; +} + +:root[data-design='fold'] .error-content { + flex: 0 1 560px; + margin-inline-end: auto; + padding: 0; + text-align: start; +} + +:root[data-design='fold'] h1 { + flex-direction: row; + align-items: baseline; + gap: 14px; +} + +:root[data-design='fold'] .error-code, +:root[data-design='fold'] .error-title { + font-size: 26px; + line-height: 1.2; + letter-spacing: -0.025em; +} + +:root[data-design='fold'] .error-code { + flex: none; + color: var(--muted); + font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace; + font-weight: 400; + letter-spacing: -0.045em; +} + +:root[data-design='fold'] .error-description { + margin-block-start: 12px; + font-size: 14px; + line-height: 1.6; + text-wrap: pretty; +} + +:root[data-design='fold'] .error-action { + margin-block-start: 24px; +} + +@supports (corner-shape: squircle) { + .error-action { + border-radius: 12px; + corner-shape: squircle; + } +} + +@media (pointer: coarse) { + .error-action { + min-block-size: 40px; + } +} + +@media (max-width: 620px) { + :root[data-design='fold'] .error-page { + padding: 36px 30px; + } + + :root[data-design='fold'] .error-code, + :root[data-design='fold'] .error-title { + font-size: 24px; + } +} + +@media (max-width: 440px) { + .error-code { + font-size: 56px; + } + + :root[data-design='grid'] .error-page { + grid-template-columns: 24px minmax(0, 1fr) 24px; + } + + :root[data-design='grid'] .error-content { + padding: 24px; + } + + :root[data-design='fold'] .error-code { + display: none; + } + + :root[data-design='fold'] .error-action { + margin-block-start: 20px; + } +} diff --git a/packages/void/src/runtime/routing-errors.ts b/packages/void/src/runtime/routing-errors.ts index 060c41714..b7e66e967 100644 --- a/packages/void/src/runtime/routing-errors.ts +++ b/packages/void/src/runtime/routing-errors.ts @@ -1,4 +1,7 @@ import type { Context } from 'hono'; +import { renderDefaultErrorPage } from './error-pages/index.ts'; + +type ErrorPageStatus = 404 | 500; type StackFrame = { functionName: string; @@ -12,10 +15,15 @@ type StackFrame = { type ErrorDetails = { name: string; message: string; stack: string; frames: Array }; export type ErrorRendererOptions = { dev?: boolean; projectRoot: string; homeDir: string }; export type ErrorRenderer = { - createResponse: (request: Request, status: number, title: string, error?: unknown) => Response; + createResponse: ( + request: Request, + status: ErrorPageStatus, + title: string, + error?: unknown, + ) => Response; serveCustomPage: ( context: Context, - status: number, + status: ErrorPageStatus, title: string, error?: unknown, ) => Promise; @@ -567,80 +575,17 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere return /<\/head\s*>/i.test(html) ? html.replace(/<\/head\s*>/i, css + '') : css + html; } - function renderErrorPage(status: number, title: string, error?: unknown): string { - const heading = status + ': ' + title; - const prefix = status + ': '; - const overlay = - dev && status === 500 && error ? renderDevErrorOverlay(status, title, error) : ''; - return ` - - - - - ${escapeHtml(heading)} - ${getErrorAssetCss(error)} - - - -
-

${escapeHtml(prefix)}${escapeHtml(title)}

-
- ${overlay} - -`; + function renderErrorPage(status: ErrorPageStatus, title: string, error?: unknown): string { + const page = renderDefaultErrorPage(status); + const html = injectErrorAssetCss(page, getErrorAssetCss(error)); + return dev && status === 500 && error + ? injectDevErrorOverlay(html, renderDevErrorOverlay(status, title, error)) + : html; } function createErrorResponse( request: Request, - status: number, + status: ErrorPageStatus, title: string, error?: unknown, ): Response { @@ -655,7 +600,7 @@ export function createErrorRenderer(options: ErrorRendererOptions): ErrorRendere async function serveCustomPage( c: Context, - status: number, + status: ErrorPageStatus, fallbackText: string, error?: unknown, ): Promise { diff --git a/packages/void/test/integration/error-pages.test.ts b/packages/void/test/integration/error-pages.test.ts new file mode 100644 index 000000000..80da3bded --- /dev/null +++ b/packages/void/test/integration/error-pages.test.ts @@ -0,0 +1,117 @@ +import { chromium, type Browser } from 'playwright'; +import { afterAll, beforeAll, describe, expect, it } from 'vite-plus/test'; + +// Exercise the bundle without loading its global declarations alongside source declarations. +const { createErrorRenderer } = (await import( + new URL('../../dist/runtime/routing.mjs', import.meta.url).href +)) as typeof import('../../src/runtime/routing-errors.ts'); + +const errors = createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }); +let browser: Browser; + +beforeAll(async () => { + browser = await chromium.launch(); +}); + +afterAll(async () => { + await browser?.close(); +}); + +describe('default error page actions', () => { + it.each([ + ['signal', 0], + ['grid', 0.4], + ['fold', 0.8], + ] as const)( + 'renders the %s layout for both statuses without fetching assets', + async (design, random) => { + const page = await browser.newPage(); + const failures: Array = []; + const requests: Array = []; + page.on('pageerror', (error) => failures.push(error.message)); + page.on('request', (request) => requests.push(request.url())); + await page.addInitScript((value) => { + Math.random = () => value; + }, random); + await page.route('https://app.test/**', async (route) => { + const status = new URL(route.request().url()).pathname === '/404' ? 404 : 500; + const response = errors.createResponse(new Request(route.request().url()), status, 'Error'); + return route.fulfill({ status, contentType: 'text/html', body: await response.text() }); + }); + try { + for (const status of [404, 500]) { + await page.goto(`https://app.test/${status}`); + expect(await page.locator('html').getAttribute('data-design')).toBe(design); + expect(await page.getByRole('heading', { level: 1 }).count()).toBe(1); + expect(await page.getByRole('heading', { level: 1 }).innerText()).toContain( + String(status), + ); + expect(await page.evaluate(() => document.fonts.size)).toBe(0); + } + expect(requests).toEqual(['https://app.test/404', 'https://app.test/500']); + expect(failures).toEqual([]); + } finally { + await page.close(); + } + }, + ); + + it('shows Go back only when a previous entry exists and returns to that entry', async () => { + const page = await browser.newPage(); + page.setDefaultTimeout(10_000); + await page.route('https://app.test/**', async (route) => { + if (new URL(route.request().url()).pathname === '/article') { + return route.fulfill({ + contentType: 'text/html', + body: 'Missing page', + }); + } + const response = errors.createResponse(new Request(route.request().url()), 404, 'Not Found'); + return route.fulfill({ + status: response.status, + contentType: 'text/html', + body: await response.text(), + }); + }); + try { + await page.goto('https://app.test/missing'); + const back = page.getByRole('button', { name: 'Go back', includeHidden: true }); + expect(await back.isVisible()).toBe(false); + await page.goto('https://app.test/article'); + await page.getByRole('link', { name: 'Missing page' }).click(); + expect(await back.isVisible()).toBe(true); + await back.click(); + await page.waitForURL('https://app.test/article'); + } finally { + await page.close(); + } + }); + + it('retries the current URL including its query without JavaScript or extra assets', async () => { + const context = await browser.newContext({ javaScriptEnabled: false }); + const page = await context.newPage(); + page.setDefaultTimeout(10_000); + const requests: Array = []; + const url = 'https://app.test/account?view=activity'; + await page.route('https://app.test/**', async (route) => { + requests.push(route.request().url()); + if (requests.length > 1) { + return route.fulfill({ contentType: 'text/html', body: '

Recovered

' }); + } + const response = errors.createResponse(new Request(url), 500, 'Internal Server Error'); + return route.fulfill({ + status: response.status, + contentType: 'text/html', + body: await response.text(), + }); + }); + try { + await page.goto(url); + await page.getByRole('link', { name: 'Reload page' }).click(); + await page.getByRole('heading', { name: 'Recovered' }).waitFor(); + expect(requests).toEqual([url, url]); + } finally { + await context.close(); + } + }); +}); diff --git a/packages/void/test/unit/error-page-assets.test.ts b/packages/void/test/unit/error-page-assets.test.ts new file mode 100644 index 000000000..3a9ccc166 --- /dev/null +++ b/packages/void/test/unit/error-page-assets.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it } from 'vite-plus/test'; +import { inlineErrorPageAssets } from '../../src/runtime/error-pages/inline.ts'; + +const template = ` + + + + + + + + + +

404

`; + +describe('error page assets', () => { + it('inlines formatted asset blocks, removes preview comments and preserves page markup', () => { + const css = 'body::before { content: "$&"; }'; + const js = 'console.log("$` $& $1");'; + const html = inlineErrorPageAssets(template, css, js); + expect(html).toContain(``); + expect(html).toContain(``); + expect(html).toContain('

404

'); + expect(html).not.toContain('including this comment'); + expect(html).not.toMatch(/]+src\s*=/); + }); + + it('rejects missing and duplicate asset markers', () => { + for (const name of ['css', 'js']) { + const marker = ``; + expect(() => inlineErrorPageAssets(template.replace(marker, ''), '', '')).toThrow( + `${name} block`, + ); + expect(() => inlineErrorPageAssets(template + marker, '', '')).toThrow(`${name} block`); + } + }); + + it('rejects missing or extra closing markers', () => { + const marker = ''; + expect(() => inlineErrorPageAssets(template.replace(marker, ''), '', '')).toThrow('endinject'); + expect(() => inlineErrorPageAssets(template + marker, '', '')).toThrow('endinject'); + }); +}); diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 2fca4b381..0cab495ef 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -690,6 +690,40 @@ describe('typed Pages and SSR execution', () => { }); describe('typed error rendering', () => { + it.each([ + [404, 'Not Found'], + [500, 'Internal Server Error'], + ] as const)( + 'serves an HTML %s page when custom assets are unavailable', + async (status, title) => { + const app = new Hono(); + const errors = createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }); + app.get('/page', (c) => errors.serveCustomPage(c, status, title)); + const response = await app.fetch(new Request('https://app.test/page'), { + ASSETS: { + fetch: async () => { + throw new Error('assets unavailable'); + }, + }, + }); + expect(response.status).toBe(status); + expect(response.headers.get('content-type')).toContain('text/html'); + expect(await response.text()).toContain(String(status)); + }, + ); + it.each([404, 500] as const)('serves a custom %s page unchanged', async (status) => { + const app = new Hono(); + const errors = createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }); + app.get('/page', (c) => errors.serveCustomPage(c, status, 'Custom error')); + const body = 'Custom error page'; + const fetch = async (request: Request) => + new URL(request.url).pathname === `/${status}.html` + ? new Response(body) + : new Response('Not Found', { status: 404 }); + const response = await app.fetch(new Request('https://app.test/page'), { ASSETS: { fetch } }); + expect(response.status).toBe(status); + expect(await response.text()).toBe(body); + }); it('escapes exceptions, renders causes and editor links in development and suppresses details in production', async () => { const error = new Error('', { cause: new Error('nested failure') }); error.stack = 'Error: private\n at render (/project/pages/index.ts:12:3)'; diff --git a/packages/void/vite.config.ts b/packages/void/vite.config.ts index 90217727b..71c0c6b37 100644 --- a/packages/void/vite.config.ts +++ b/packages/void/vite.config.ts @@ -147,9 +147,9 @@ export default defineConfig({ clean: true, plugins: [ { - name: 'void:raw-css', + name: 'void:raw-assets', load: { - filter: { id: /\.css\?raw$/ }, + filter: { id: /\.(?:css|html|js)\?raw$/ }, handler(id) { const file = id.slice(0, -'?raw'.length); this.addWatchFile(file);