diff --git a/docs/guide/pages-routing/loaders.md b/docs/guide/pages-routing/loaders.md index a3eb14044..05fca4214 100644 --- a/docs/guide/pages-routing/loaders.md +++ b/docs/guide/pages-routing/loaders.md @@ -29,6 +29,23 @@ export const loader = defineHandler(async (c) => { Void preserves `Date`, `Map`, `Set`, `bigint`, `RegExp`, `URL`, `undefined`, and repeated or cyclic references in page props and shared data. These values keep their types during server rendering, hydration, navigation, and deferred loading. Return data directly from your loader; explicitly returning `Response.json()` uses ordinary JSON conversion instead. Functions and custom class instances cannot be sent as page data. +## Response Status + +Use `c.status()` to render the current page with an HTTP error status, such as `403` or `503`. Return the page's props as usual: + +```ts +// pages/maintenance.server.ts +import { defineHandler } from 'void/handler'; + +export const loader = defineHandler((c) => { + c.status(503); + c.header('Retry-After', '120'); + return { message: 'Down for maintenance' }; +}); +``` + +The status and headers apply to both the HTML document and client-side navigation responses. Client visits still display the page with its props and layouts. Middleware can also set the status before `await next()`. For pages with deferred props, set the status in the loader before streaming starts. + ## Using the Data in Page Components The page component receives the loader's result as props. Export the inferred type from the server file so the component stays in sync: diff --git a/packages/void/src/pages/protocol.ts b/packages/void/src/pages/protocol.ts index 912df0681..aaf153536 100644 --- a/packages/void/src/pages/protocol.ts +++ b/packages/void/src/pages/protocol.ts @@ -289,6 +289,11 @@ async function renderPageHtml( function pageJsonResponse(c: CloudContext, pageObj: PageObject): Response { return new Response(serializePageData(pageObj), { + // Hono's lazy c.res getter ignores the private status set by c.status(). + // Use its response factory to read that status without changing our header merging. + // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L403-L407 + // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L652-L659 + status: c.newResponse(null).status, headers: pageResponseHeaders(c, { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION, @@ -581,6 +586,11 @@ function renderedResponse( assetTags?: AssetTags, ): Response { return new Response(result, { + // Hono's lazy c.res getter ignores the private status set by c.status(). + // Use its response factory to read that status without changing our header merging. + // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L403-L407 + // https://github.com/honojs/hono/blob/v4.13.17/src/context.ts#L652-L659 + status: c.newResponse(null).status, headers: adjustDevCssCsp( pageResponseHeaders(c, { 'content-type': contentType, diff --git a/packages/void/test/integration/http-errors-playground.test.ts b/packages/void/test/integration/http-errors-playground.test.ts new file mode 100644 index 000000000..cc55d13cf --- /dev/null +++ b/packages/void/test/integration/http-errors-playground.test.ts @@ -0,0 +1,345 @@ +import { join } from 'node:path'; +import spawn from 'cross-spawn'; +import type { Browser, Page } from 'playwright'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vite-plus/test'; +import { + createIsolatedPlayground, + killChild, + startServer, + type IsolatedPlayground, + type StartedServer, +} from '../../../../test/integration-helpers.ts'; +import { launchPagesBrowser } from '../../../../test/pages-browser.ts'; +import { PAGES_PROTOCOL_VERSION } from '../../../edge/src/pages-protocol.ts'; +import { deserializePageData } from '../../src/pages/serialize.ts'; + +const repository = join(import.meta.dirname, '../../../..'); +const pagesHeaders = { + 'X-VoidPages': 'true', + 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION, +}; +const renderedExamples = [ + { + path: '/loaders/status/403', + status: 403, + heading: 'Status set in a loader', + message: 'Your account is banned', + retryAfter: '120', + }, + { + path: '/loaders/status/503', + status: 503, + heading: 'Status set in a loader', + message: 'Down for maintenance', + retryAfter: '120', + }, + { + path: '/loaders/validated', + status: 503, + heading: 'Status set in a validated loader', + message: 'Down for maintenance', + retryAfter: null, + }, + { + path: '/loaders/deferred', + status: 429, + heading: 'Status set before deferred streaming', + message: 'Too many requests', + retryAfter: '120', + }, + { + path: '/middleware/application', + status: 503, + heading: 'Status set in application middleware', + message: 'Down for maintenance', + retryAfter: '120', + }, + { + path: '/middleware/handler', + status: 403, + heading: 'Status set in handler middleware', + message: 'Your account is banned', + retryAfter: null, + }, + { + path: '/loaders/unexpected', + status: 500, + heading: 'Root error boundary', + message: 'Internal Server Error', + retryAfter: null, + }, + { + path: '/loaders/not-found', + status: 404, + heading: 'Not-found boundary', + message: 'This page does not exist', + retryAfter: null, + }, + ...( + [ + { status: 401, message: 'Please sign in' }, + { status: 403, message: 'Your account is banned' }, + { status: 410, message: 'This page is permanently gone' }, + { status: 429, message: 'Too many requests' }, + { status: 503, message: 'Down for maintenance' }, + ] as const + ).map(({ status, message }) => ({ + path: `/loaders/errors/${status}`, + status, + heading: 'Nested error boundary', + message, + retryAfter: null, + })), + ...( + [ + { status: 403, message: 'Your account is banned' }, + { status: 503, message: 'Down for maintenance' }, + ] as const + ).map(({ status, message }) => ({ + path: `/middleware/errors/${status}`, + status, + heading: 'Root error boundary', + message, + retryAfter: null, + })), +] as const; + +type PageData = { + component: string; + props: Record; + url: string; + deferredKeys?: Record; +}; + +describe.each(['dev', 'production'] as const)('HTTP errors playground (%s)', (mode) => { + let playground: IsolatedPlayground; + let server: StartedServer; + let browser: Browser; + let base: string; + + function expectedMessage(example: (typeof renderedExamples)[number]) { + return mode === 'dev' && example.path === '/loaders/unexpected' + ? 'Unexpected loader failure' + : example.message; + } + + beforeAll(async () => { + playground = createIsolatedPlayground(repository, 'playground/http-errors'); + if (mode === 'production') { + const build = spawn.sync('vp', ['build'], { + cwd: playground.root, + encoding: 'utf8', + timeout: 60_000, + maxBuffer: 10 * 1024 * 1024, + env: { ...process.env, NODE_ENV: 'production' }, + }); + expect(build.status, `${build.stdout ?? ''}${build.stderr ?? ''}`).toBe(0); + } + server = await startServer( + (port) => + mode === 'dev' + ? spawn('vp', ['dev', '--host', '127.0.0.1', '--port', String(port), '--strictPort'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + }) + : spawn(process.execPath, ['dist/ssr/index.js'], { + cwd: playground.root, + stdio: ['ignore', 'pipe', 'pipe'], + env: { ...process.env, PORT: String(port), NODE_ENV: 'production' }, + }), + 30_000, + { expectBody: 'HTTP Errors Example' }, + ); + base = server.base; + browser = await launchPagesBrowser(); + }); + + afterAll(async () => { + await browser?.close(); + if (server) { + await killChild(server.child); + } + playground?.cleanup(); + }); + + for (const representation of ['HTML', 'Pages'] as const) { + it.each(renderedExamples)( + `${representation}: $path renders $heading with HTTP $status`, + async (example) => { + const pages = representation === 'Pages'; + const response = await fetch(base + example.path, { headers: pages ? pagesHeaders : {} }); + expect(response.status).toBe(example.status); + expect(response.headers.get('Retry-After')).toBe(example.retryAfter); + const body = await response.text(); + if (!pages) { + expect(response.headers.get('content-type')).toContain('text/html'); + expect(body).toContain(example.heading); + expect(body).toContain(expectedMessage(example)); + expect(body).toContain('aria-label="Example sections"'); + expect(body).toContain('example-page-loaded-toast'); + if (example.path === '/loaders/deferred') { + expect(body).toContain('Waiting one second for deferred props'); + expect(body).toContain('window.__resolveDeferred("details"'); + } + } else { + expect(response.headers.get('X-VoidPages')).toBe('true'); + const lines = body.trim().split('\n'); + const data = deserializePageData(lines[0]); + expect(data.url).toBe(example.path); + if (example.path === '/loaders/deferred') { + expect(response.headers.get('content-type')).toContain('application/x-ndjson'); + expect(data.deferredKeys).toEqual({ details: 'details' }); + expect(lines).toHaveLength(2); + expect(deserializePageData(lines[1])).toMatchObject({ + __deferred: 'details', + data: example.message, + }); + } else { + expect(response.headers.get('content-type')).toContain('application/json'); + expect(data.props).toMatchObject( + example.path.includes('/errors/') || example.status === 404 || example.status === 500 + ? { error: { status: example.status, message: expectedMessage(example) } } + : { status: example.status, message: example.message }, + ); + } + } + if (example.path.startsWith('/middleware/errors/')) { + expect(server.output()).not.toContain('Middleware-error page loader ran'); + } + }, + ); + } + + describe('browser navigation', () => { + let page: Page; + + beforeEach(async () => { + page = await browser.newPage(); + page.setDefaultTimeout(10_000); + }); + afterEach(async () => { + await page?.close(); + }); + + async function openHome() { + await page.goto(base); + // The layout effect is also the example's observable hydration signal. + await page.waitForSelector('.example-page-loaded-toast:popover-open'); + await page.evaluate(() => Reflect.set(globalThis, '__HTTP_ERRORS_DOCUMENT__', true)); + await page + .locator('header') + .evaluate((element) => element.setAttribute('data-retained', 'yes')); + } + + for (const navigation of ['Document load', 'Client navigation'] as const) { + it.each(renderedExamples)( + `${navigation}: $path renders $heading with HTTP $status`, + async (example) => { + await openHome(); + const responsePromise = page.waitForResponse( + (response) => + response.url() === base + example.path && + (navigation === 'Document load' + ? response.request().isNavigationRequest() + : response.request().headers()['x-voidpages'] === 'true'), + ); + await page + .locator(`a[href="${example.path}"]`) + .getByText(navigation, { exact: true }) + .click(); + const response = await responsePromise; + expect(response.status()).toBe(example.status); + expect(response.headers()['retry-after'] ?? null).toBe(example.retryAfter); + if (navigation === 'Document load') { + expect(response.headers()['content-type']).toContain('text/html'); + await page.waitForSelector('.example-page-loaded-toast:popover-open'); + } else { + expect(response.headers()['x-voidpages']).toBe('true'); + } + await expect.poll(() => page.locator('main h1').textContent()).toBe(example.heading); + await expect + .poll(() => page.locator('main').textContent(), { timeout: 10_000 }) + .toContain(expectedMessage(example)); + expect(await page.locator('header').count()).toBe(1); + if (example.path.startsWith('/loaders/errors/')) { + expect( + await page + .getByText('Nested layout for HTTP exception examples.', { exact: true }) + .count(), + ).toBe(1); + expect( + await page + .getByRole('heading', { name: 'Loader exception example', exact: true }) + .count(), + ).toBe(0); + } + if (example.path.startsWith('/middleware/errors/')) { + expect(server.output()).not.toContain('Middleware-error page loader ran'); + expect( + await page + .getByRole('heading', { name: 'Middleware exception example', exact: true }) + .count(), + ).toBe(0); + } + const retained = navigation === 'Client navigation'; + expect( + await page.evaluate(() => Reflect.get(globalThis, '__HTTP_ERRORS_DOCUMENT__')), + ).toBe(retained ? true : undefined); + expect(await page.locator('header').getAttribute('data-retained')).toBe( + retained ? 'yes' : null, + ); + }, + ); + } + + it('offers document and client navigation for every example', async () => { + await openHome(); + const paths = renderedExamples.map(({ path }) => path).sort(); + for (const name of ['Document load', 'Client navigation']) { + const links = await page.getByRole('link', { name, exact: true }).all(); + const targets = await Promise.all(links.map((link) => link.getAttribute('href'))); + expect(targets.sort()).toEqual(paths); + } + }); + + it.each(['hover', 'focus'] as const)( + 'prefetches complete deferred props on %s without navigating', + async (interaction) => { + await openHome(); + const responsePromise = page.waitForResponse( + (response) => + response.url() === base + '/loaders/deferred' && + response.request().headers().purpose === 'prefetch', + ); + const link = page.getByRole('link', { name: 'Hover or focus to prefetch deferred data' }); + await link[interaction](); + const response = await responsePromise; + expect(response.status()).toBe(429); + expect(response.headers()['retry-after']).toBe('120'); + expect(response.headers()['content-type']).toContain('application/json'); + expect(response.request().headers()['x-voidpages-version']).toBe(PAGES_PROTOCOL_VERSION); + expect(deserializePageData(await response.text())).toMatchObject({ + props: { details: 'Too many requests' }, + }); + expect(page.url()).toBe(base + '/'); + expect(await page.locator('header').getAttribute('data-retained')).toBe('yes'); + }, + ); + + it('shows the document-load toast once and does not reopen it on client navigation', async () => { + await openHome(); + await page.waitForSelector('.example-page-loaded-toast:popover-open', { state: 'hidden' }); + await page + .locator('a[href="/loaders/status/403"]') + .getByText('Client navigation', { exact: true }) + .click(); + await expect.poll(() => page.locator('main h1').textContent()).toBe('Status set in a loader'); + expect(await page.locator('.example-page-loaded-toast:popover-open').count()).toBe(0); + expect(await page.locator('header').getAttribute('data-retained')).toBe('yes'); + await page.getByRole('link', { name: 'HTTP Errors Example', exact: true }).click(); + await expect.poll(() => page.locator('main h1').textContent()).toBe('HTTP Errors Example'); + expect(await page.locator('.example-page-loaded-toast:popover-open').count()).toBe(0); + expect(await page.title()).toBe('HTTP Errors Example'); + }); + }); +}); diff --git a/packages/void/test/integration/page-errors.test.ts b/packages/void/test/integration/page-errors.test.ts index 5e93e8403..ebb549838 100644 --- a/packages/void/test/integration/page-errors.test.ts +++ b/packages/void/test/integration/page-errors.test.ts @@ -82,6 +82,21 @@ describe.each(frameworks)('Pages errors with %s', (framework) => { `import { defineMiddleware } from 'void/handler'; export default defineMiddleware(async (c,next)=>{c.set('shared',{viewer:'alice',...(c.req.header('X-Test-No-Root') ? { noRoot: true } : {})}); c.header('X-Security','retained'); const guard=c.req.header('X-Test-Guard'); if(guard==='not-found') return c.notFound(); if(guard==='explicit') return c.text('Hidden by guard',404); await next(); if(guard==='after-not-found') c.res=await c.notFound();});`, ); write('pages/style.css', 'nav { color: rgb(20, 80, 100); }'); + write( + 'middleware/status.ts', + `import { defineMiddleware } from 'void/handler'; +import { HTTPException } from 'hono/http-exception'; +export default defineMiddleware(async (c, next) => { + const status = c.req.header('X-Test-Error-Status'); + if (status === '403' || status === '503') { + c.header('Retry-After', '120'); + throw new HTTPException(status === '403' ? 403 : 503, { + message: status === '403' ? 'Your account is banned' : 'Down for maintenance', + }); + } + await next(); +});`, + ); const nav = ''; let rootLayout = component(nav, '', true); @@ -119,6 +134,16 @@ describe.each(frameworks)('Pages errors with %s', (framework) => { write(`pages/_error.${extension}`, component('

Error page

{{error.message}}

')); } write(`pages/items/[id].${extension}`, component('

Item

')); + write(`pages/status/[code].${extension}`, component('

Status page

')); + write( + 'pages/status/[code].server.ts', + `import { defineHandler } from 'void/handler'; +export const loader = defineHandler(c => { + c.status(c.req.param('code') === '403' ? 403 : 503); + c.header('Retry-After', '120'); + return {}; +});`, + ); for (const name of ['falsy', 'island-falsy.island']) { write(`pages/${name}.${extension}`, component('

Broken view

', 'throw undefined;')); } @@ -295,6 +320,46 @@ describe.each(frameworks)('Pages errors with %s', (framework) => { expect(await response.text()).toContain('Missing island page'); }); + it.each( + ([403, 503] as const).flatMap((status) => + (['HTML', 'Pages JSON'] as const).map((format) => ({ status, format })), + ), + )( + 'renders middleware errors through the root page boundary with HTTP $status ($format)', + async ({ status, format }) => { + const message = status === 403 ? 'Your account is banned' : 'Down for maintenance'; + // This route throws a different error if its loader is reached. + const response = await fetch(base + '/items/unexpected', { + headers: { + 'X-Test-Error-Status': String(status), + ...(format === 'Pages JSON' + ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION } + : { Accept: 'text/html' }), + }, + }); + const body = await response.text(); + expect(response.status).toBe(status); + expect(body).not.toContain('SECRET_'); + if (format === 'Pages JSON') { + expect(response.headers.get('X-VoidPages')).toBe('true'); + expect(deserializePageData(body)).toMatchObject({ + props: { error: { status, message } }, + shared: { viewer: 'alice' }, + url: '/items/unexpected', + }); + } else { + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(body).toContain('Error page'); + expect(body).toContain(message); + expect(body).toContain('data-layout="root"'); + expect(body).toContain('stylesheet'); + } + expect(response.headers.get('Retry-After')).toBe('120'); + expect(response.headers.get('X-Security')).toBe('retained'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }, + ); + it('returns status-correct documents and marked error payloads without exposing secrets', async () => { for (const [path, status] of [ ['/unknown', 404], @@ -344,6 +409,39 @@ describe.each(frameworks)('Pages errors with %s', (framework) => { expect(response.status).toBe(404); expect(await response.text()).toBe('API missing'); }); + it('renders application-defined error statuses on document loads and client visits', async () => { + const page = await browser.newPage(); + page.setDefaultTimeout(8_000); + try { + for (const status of [403, 503]) { + const path = `/status/${status}`; + const documentResponse = await fetch(base + path); + expect(documentResponse.status).toBe(status); + expect(documentResponse.headers.get('Retry-After')).toBe('120'); + expect(await documentResponse.text()).toContain('Status page'); + + await page.goto(base); + await page.waitForSelector('#app[data-hydrated="true"]'); + await page.evaluate(() => + document.querySelector('nav')!.setAttribute('data-retained', 'yes'), + ); + const navigation = page.waitForResponse( + (response) => + response.url() === base + path && + response.request().headers()['x-voidpages'] === 'true', + ); + await page.evaluate(`window.testRouter.visit(${JSON.stringify(path)})`); + const response = await navigation; + expect(response.status()).toBe(status); + expect(response.headers()['x-voidpages']).toBe('true'); + await expect.poll(() => page.locator('h1').textContent()).toBe('Status page'); + expect(await page.locator('nav').getAttribute('data-retained')).toBe('yes'); + } + } finally { + await page.close(); + } + }); + it('keeps the app and outer layout mounted across 404s and client errors', async () => { const page = await browser.newPage(); const errors: Array = []; diff --git a/packages/void/test/unit/page-boundaries.test.ts b/packages/void/test/unit/page-boundaries.test.ts index 3609bad53..28b549767 100644 --- a/packages/void/test/unit/page-boundaries.test.ts +++ b/packages/void/test/unit/page-boundaries.test.ts @@ -167,6 +167,106 @@ describe('boundary render plans', () => { }); describe('Pages loader boundary protocol', () => { + describe.each(['HTML', 'Pages JSON'] as const)( + 'HTTP exception recovery through page boundaries (%s)', + (format) => { + it.each([ + [401, 'Please sign in'], + [403, 'Your account is banned'], + [410, 'This item was deleted'], + [429, 'Too many requests'], + [503, 'Down for maintenance'], + ] as const)('renders HTTP %s through the nearest error boundary', async (status, message) => { + const app = new Hono(); + app.onError(createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }).onError); + let loaderCalls = 0; + app.get( + '/items/1', + createPageGetHandler({ + componentId: 'items/item', + load: async () => ({ + loader: defineHandler((c) => { + loaderCalls++; + c.header('Retry-After', '120'); + throw new HTTPException(status, { message }); + }), + }), + render: async (page) => + `
${JSON.stringify(page.props)}
`, + assets: async () => ({ css: '', body: '', preloads: '' }), + headConfig: null, + renderOptions: { + boundaries: { notFound: 'not-found-plan', errors: ['items-error', 'root-error'] }, + }, + }), + ); + const response = await app.request('https://app.test/items/1', { + headers: + format === 'Pages JSON' + ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION } + : { Accept: 'text/html' }, + }); + const body = await response.text(); + expect(loaderCalls).toBe(1); + expect(response.status).toBe(status); + if (format === 'Pages JSON') { + expect(response.headers.get('X-VoidPages')).toBe('true'); + expect(deserializePageData(body)).toMatchObject({ + component: 'items-error', + props: { error: { status, message } }, + url: '/items/1', + }); + } else { + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(body).toContain('data-component="items-error"'); + expect(body).toContain(JSON.stringify({ error: { status, message } })); + } + expect(response.headers.get('Retry-After')).toBe('120'); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + }); + + it.each([302, 503] as const)( + 'passes through HTTP %s with an explicit response', + async (status) => { + const app = new Hono(); + app.onError(createErrorRenderer({ projectRoot: '/project', homeDir: '/home' }).onError); + app.get( + '/', + createPageGetHandler({ + componentId: 'index', + load: async () => ({ + loader: () => { + throw new HTTPException(status, { + res: new Response('Explicit response', { + status, + headers: status === 302 ? { Location: '/login' } : { 'Retry-After': '120' }, + }), + }); + }, + }), + render: async () => '
Error boundary
', + assets: async () => ({ css: '', body: '', preloads: '' }), + headConfig: null, + renderOptions: { boundaries: { errors: ['root-error'] } }, + }), + ); + const response = await app.request('https://app.test/', { + headers: + format === 'Pages JSON' + ? { 'X-VoidPages': 'true', 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION } + : { Accept: 'text/html' }, + }); + expect(response.status).toBe(status); + expect(response.headers.has('X-VoidPages')).toBe(false); + expect(response.headers.get(status === 302 ? 'Location' : 'Retry-After')).toBe( + status === 302 ? '/login' : '120', + ); + expect(await response.text()).toBe('Explicit response'); + }, + ); + }, + ); + it.each(['returned', 'thrown', 'empty-message', 'unexpected'])( 'renders %s failures as marked, uncached Pages data', async (kind) => { diff --git a/packages/void/test/unit/routing-runtime.test.ts b/packages/void/test/unit/routing-runtime.test.ts index 9c3c2c169..457b1042f 100644 --- a/packages/void/test/unit/routing-runtime.test.ts +++ b/packages/void/test/unit/routing-runtime.test.ts @@ -1702,6 +1702,112 @@ describe('page loaders and actions composed with defineHandler middleware', () = expect(await html.text()).toContain('{"notes":["a"]}'); }); + describe.each(['HTML', 'Pages JSON'] as const)( + 'Application-defined page status codes (%s)', + (format) => { + it.each( + ([403, 503] as const).flatMap((status) => + (['loader', 'validated loader', 'app middleware', 'handler middleware'] as const).map( + (source) => ({ + status, + source, + }), + ), + ), + )('renders the page with HTTP $status set by $source', async ({ status, source }) => { + const message = status === 403 ? 'Your account is banned' : 'Down for maintenance'; + const setStatus = defineMiddleware(async (c, next) => { + c.status(status); + c.header('Retry-After', '120'); + await next(); + }); + const loader = vi.fn((c: CloudContext) => { + if (source === 'loader' || source === 'validated loader') { + c.status(status); + c.header('Retry-After', '120'); + } + return { message }; + }); + const app = createPageApp( + { + loader: + source === 'handler middleware' + ? defineHandler(setStatus, loader) + : source === 'validated loader' + ? defineHandler.withValidator({})(loader) + : defineHandler(loader), + }, + async (c, next) => { + c.header('Set-Cookie', 'session=renewed; HttpOnly', { append: true }); + c.header('Set-Cookie', 'locale=en', { append: true }); + if (source === 'app middleware') { + return setStatus(c, next); + } + await next(); + }, + ); + const response = await get(app, format === 'HTML' ? {} : undefined); + const body = await response.text(); + + expect(loader).toHaveBeenCalledTimes(1); + expect(response.headers.get('Retry-After')).toBe('120'); + expect(response.headers.getSetCookie()).toEqual(['session=renewed; HttpOnly', 'locale=en']); + if (format === 'Pages JSON') { + expect(response.headers.get('X-VoidPages')).toBe('true'); + expect(decodePageData(JSON.parse(body))).toMatchObject({ + component: 'page', + props: { message }, + }); + } else { + expect(response.headers.get('Content-Type')).toContain('text/html'); + expect(body).toContain(message); + } + expect(response.status).toBe(status); + }); + + it('preserves the loader status while streaming deferred props', async () => { + const app = createPageApp({ + loader: defineHandler((c) => { + c.status(429); + return { slow: defer(async () => 'Try again later') }; + }), + }); + const response = await get(app, format === 'HTML' ? {} : undefined); + const body = await response.text(); + expect(response.status).toBe(429); + if (format === 'Pages JSON') { + expect(response.headers.get('Content-Type')).toBe('application/x-ndjson'); + expect(response.headers.get('X-VoidPages')).toBe('true'); + expect(body).toContain( + serializePageData({ __deferred: 'slow', data: 'Try again later' }), + ); + } else { + expect(body).toContain('__resolveDeferred'); + expect(body).toContain('Try again later'); + } + }); + }, + ); + + it('preserves the loader status when prefetching deferred page data', async () => { + const app = createPageApp({ + loader: defineHandler((c) => { + c.status(503); + return { slow: defer(async () => 'Try again later') }; + }), + }); + const response = await get(app, { + 'X-VoidPages': 'true', + 'X-VoidPages-Version': PAGES_PROTOCOL_VERSION, + Purpose: 'prefetch', + }); + expect(response.status).toBe(503); + expect(response.headers.get('Content-Type')).toBe('application/json'); + expect(decodePageData(await response.json())).toMatchObject({ + props: { slow: 'Try again later' }, + }); + }); + it('re-renders the page after an action with middleware, or shows its field errors', async () => { const action = (message?: string) => defineHandler( diff --git a/playground/http-errors/README.md b/playground/http-errors/README.md new file mode 100644 index 000000000..5c696e622 --- /dev/null +++ b/playground/http-errors/README.md @@ -0,0 +1,18 @@ +# HTTP errors + +Explore HTTP statuses, error boundaries, and navigation behavior through React Pages API examples. + +From the repository root: + +```sh +vp run --filter ./playground/http-errors dev +``` + +Run the HTTP and browser scenarios from the repository root: + +```sh +vp test run packages/void/test/integration/http-errors-playground.test.ts +``` + +The suite runs an isolated copy in development and production, covering response statuses, +error boundaries, document loads, client navigation, deferred streaming, and prefetching. diff --git a/playground/http-errors/middleware/http-errors.ts b/playground/http-errors/middleware/http-errors.ts new file mode 100644 index 000000000..ae116daf3 --- /dev/null +++ b/playground/http-errors/middleware/http-errors.ts @@ -0,0 +1,20 @@ +import { HTTPException } from 'hono/http-exception'; +import { defineMiddleware } from 'void/handler'; +import { statusMessages } from '../src/utils.ts'; + +export default defineMiddleware(async (c, next) => { + if (c.req.path === '/middleware/application') { + c.status(503); + c.header('Retry-After', '120'); + } + + if (c.req.path === '/middleware/errors/403') { + throw new HTTPException(403, { message: statusMessages[403] }); + } + + if (c.req.path === '/middleware/errors/503') { + throw new HTTPException(503, { message: statusMessages[503] }); + } + + await next(); +}); diff --git a/playground/http-errors/package.json b/playground/http-errors/package.json new file mode 100644 index 000000000..a0471f52f --- /dev/null +++ b/playground/http-errors/package.json @@ -0,0 +1,25 @@ +{ + "name": "playground-http-errors", + "private": true, + "type": "module", + "scripts": { + "build": "vp build", + "dev": "vp dev", + "typecheck": "tsc --noEmit" + }, + "dependencies": { + "@hono/node-server": "catalog:", + "hono": "catalog:", + "react": "catalog:", + "react-dom": "catalog:" + }, + "devDependencies": { + "@types/react": "^19.3.0", + "@types/react-dom": "^19.3.0", + "@void/react": "workspace:*", + "typescript": "catalog:", + "vite": "catalog:", + "vite-plus": "catalog:", + "void": "workspace:*" + } +} diff --git a/playground/http-errors/pages/_error.tsx b/playground/http-errors/pages/_error.tsx new file mode 100644 index 000000000..8da2a97d3 --- /dev/null +++ b/playground/http-errors/pages/_error.tsx @@ -0,0 +1,12 @@ +import type { ErrorPageProps } from '@void/react'; + +export default function RootError({ error }: ErrorPageProps) { + return ( +
+

Root error boundary

+

+ {error.status}: {error.message} +

+
+ ); +} diff --git a/playground/http-errors/pages/_not-found.tsx b/playground/http-errors/pages/_not-found.tsx new file mode 100644 index 000000000..6b82e4125 --- /dev/null +++ b/playground/http-errors/pages/_not-found.tsx @@ -0,0 +1,12 @@ +import type { ErrorPageProps } from '@void/react'; + +export default function NotFound({ error }: ErrorPageProps) { + return ( +
+

Not-found boundary

+

+ {error.status}: {error.message} +

+
+ ); +} diff --git a/playground/http-errors/pages/index.tsx b/playground/http-errors/pages/index.tsx new file mode 100644 index 000000000..a078787de --- /dev/null +++ b/playground/http-errors/pages/index.tsx @@ -0,0 +1,224 @@ +import { Link } from '@void/react'; +import type { ReactNode } from 'react'; +import { SourceLink } from '../src/source-link.tsx'; + +const loaderExamples = [ + { + path: '/loaders/status/403', + title: 'Loader sets 403', + file: 'pages/loaders/status/[status].server.ts', + code: 'c.status(403);\nreturn { message: messages[403] };', + }, + { + path: '/loaders/status/503', + title: 'Loader sets 503', + file: 'pages/loaders/status/[status].server.ts', + code: "c.status(503);\nc.header('Retry-After', '120');\nreturn { message: messages[503] };", + }, + { + path: '/loaders/validated', + title: 'Validated loader sets 503', + file: 'pages/loaders/validated.server.ts', + code: 'export const loader = defineHandler.withValidator({})((c) => {\n c.status(503);\n return { message: messages[503] };\n});', + }, + { + path: '/loaders/deferred', + title: 'Loader sets 429 before deferred streaming', + file: 'pages/loaders/deferred.server.ts', + code: 'c.status(429);\nreturn { details: defer(async () => {\n await new Promise(resolve => setTimeout(resolve, 1000));\n return messages[429];\n}) };', + }, +] as const; + +const middlewareExamples = [ + { + path: '/middleware/application', + title: 'Application middleware sets 503', + file: 'middleware/http-errors.ts', + code: '// Inside application middleware, for this path:\nc.status(503);\nawait next();', + }, + { + path: '/middleware/handler', + title: 'Handler middleware sets 403', + file: 'pages/middleware/handler.server.ts', + code: 'const guard = defineMiddleware(async (c, next) => {\n c.status(403);\n await next();\n});\nexport const loader = defineHandler(guard, () => ({ message: messages[403] }));', + }, +] as const; + +function Visits({ path }: { path: string }) { + return ( +

+ Document load | Client navigation +

+ ); +} + +function Example({ + title, + file, + code, + expected, + children, +}: { + title: string; + file: string; + code?: string; + expected: ReactNode; + children: ReactNode; +}) { + return ( +
+

{title}

+

+ +

+ {code &&
{code}
} +

Expected

+ {expected} + {children} +
+ ); +} + +export default function Home() { + return ( + <> +

HTTP Errors Example

+

+ Explore how loader and middleware APIs affect response statuses, page content, and layouts. + Compare full document loads, client navigation, deferred streaming, and prefetching. +

+

+ Related:{' '} + Issue #784 |{' '} + PR #788 +

+
+ +
+

Loaders

+

+ Use c.status() to choose the HTTP status while returning props for the requested page. An + error status does not select _error or stop rendering. Both document loads and client + navigation display the page with its props and layouts. +

+
+ {loaderExamples.map(({ path, title, file, code }) => ( + + The response uses the requested status and renders the page with its props and + layouts. Client navigation keeps the app’s layout. +

+ } + > + +
+ ))} + + + HTTP 429, Purpose: prefetch, and a complete JSON payload after the one-second + deferred task. Prefetching waits for deferred props rather than streaming them. +

+ } + > +

+ Hover or focus this link without clicking, then inspect the prefetch request in the + browser’s Network panel. +

+ + Hover or focus to prefetch deferred data + +
+ + {[401, 403, 410, 429, 503].map((status) => ( + + Stops the loader and renders the nested _error boundary with the thrown HTTP + status and message. The root and nested layouts remain visible, and client + navigation keeps the app’s layout. +

+ } + > + +
+ ))} + + HTTP 500 with the root error boundary inside the app’s layout.

} + > + +
+ HTTP 404 with the not-found boundary inside the app’s layout.

} + > + +
+
+
+ +
+
+

Middleware

+

+ Application middleware runs before the route handler. Handler middleware runs as part of + defineHandler(). Both can set a status and call await next() to render the page normally. +

+
+ {middlewareExamples.map(({ path, title, file, code }) => ( + + The loader runs and the response uses the middleware’s status, rendering the page + with its props and layouts. +

+ } + > + +
+ ))} + + {[403, 503].map((status) => ( + + Stops the request before its loader runs and renders the root _error boundary with + the thrown HTTP status and message. The root layout remains visible, and client + navigation keeps it mounted. +

+ } + > + +
+ ))} +
+
+ + ); +} diff --git a/playground/http-errors/pages/layout.tsx b/playground/http-errors/pages/layout.tsx new file mode 100644 index 000000000..d1f63034c --- /dev/null +++ b/playground/http-errors/pages/layout.tsx @@ -0,0 +1,35 @@ +import { Link } from '@void/react'; +import { useEffect, useRef, type ReactNode } from 'react'; +import './styles.css'; + +export default function Layout({ children }: { children: ReactNode }) { + const toastRef = useRef(null); + + useEffect(() => { + const toast = toastRef.current!; + toast.showPopover(); + const timeout = window.setTimeout(() => toast.hidePopover(), 2500); + return () => { + window.clearTimeout(timeout); + toast.hidePopover(); + }; + }, []); + + return ( +
+
+ Page loaded +
+
+ +
+
+
{children}
+
+ ); +} diff --git a/playground/http-errors/pages/loaders/deferred.server.ts b/playground/http-errors/pages/loaders/deferred.server.ts new file mode 100644 index 000000000..b3bb57a65 --- /dev/null +++ b/playground/http-errors/pages/loaders/deferred.server.ts @@ -0,0 +1,15 @@ +import { defineHandler, defer, type InferProps } from 'void/handler'; +import { statusMessages } from '../../src/utils.ts'; + +export const loader = defineHandler((c) => { + c.status(429); + c.header('Retry-After', '120'); + return { + details: defer(async () => { + await new Promise((resolve) => setTimeout(resolve, 1000)); + return statusMessages[429]; + }), + }; +}); + +export type Props = InferProps; diff --git a/playground/http-errors/pages/loaders/deferred.tsx b/playground/http-errors/pages/loaders/deferred.tsx new file mode 100644 index 000000000..cb006c760 --- /dev/null +++ b/playground/http-errors/pages/loaders/deferred.tsx @@ -0,0 +1,18 @@ +import { Suspense, use } from 'react'; +import type { Props } from './deferred.server.ts'; + +function Details({ details }: Props) { + return

{use(details)}

; +} + +export default function DeferredPage(props: Props) { + return ( +
+

Status set before deferred streaming

+

Expected HTTP 429 for both the HTML document and the client navigation stream.

+ Waiting one second for deferred props…

}> +
+ +
+ ); +} diff --git a/playground/http-errors/pages/loaders/errors/[status].server.ts b/playground/http-errors/pages/loaders/errors/[status].server.ts new file mode 100644 index 000000000..e5c572323 --- /dev/null +++ b/playground/http-errors/pages/loaders/errors/[status].server.ts @@ -0,0 +1,9 @@ +import { HTTPException } from 'hono/http-exception'; +import { defineHandler } from 'void/handler'; +import { statusMessages, parseStatus } from '../../../src/utils.ts'; + +export const loader = defineHandler((c) => { + const status = parseStatus(c); + + throw new HTTPException(status, { message: statusMessages[status] }); +}); diff --git a/playground/http-errors/pages/loaders/errors/[status].tsx b/playground/http-errors/pages/loaders/errors/[status].tsx new file mode 100644 index 000000000..f220ecaaf --- /dev/null +++ b/playground/http-errors/pages/loaders/errors/[status].tsx @@ -0,0 +1,3 @@ +export default function LoaderPage() { + return

Loader exception example

; +} diff --git a/playground/http-errors/pages/loaders/errors/_error.tsx b/playground/http-errors/pages/loaders/errors/_error.tsx new file mode 100644 index 000000000..a7bcf3f44 --- /dev/null +++ b/playground/http-errors/pages/loaders/errors/_error.tsx @@ -0,0 +1,12 @@ +import type { ErrorPageProps } from '@void/react'; + +export default function NestedError({ error }: ErrorPageProps) { + return ( +
+

Nested error boundary

+

+ {error.status}: {error.message} +

+
+ ); +} diff --git a/playground/http-errors/pages/loaders/errors/layout.tsx b/playground/http-errors/pages/loaders/errors/layout.tsx new file mode 100644 index 000000000..50363c31f --- /dev/null +++ b/playground/http-errors/pages/loaders/errors/layout.tsx @@ -0,0 +1,10 @@ +import type { ReactNode } from 'react'; + +export default function LoaderErrorLayout({ children }: { children: ReactNode }) { + return ( +
+

Nested layout for HTTP exception examples.

+ {children} +
+ ); +} diff --git a/playground/http-errors/pages/loaders/not-found.server.ts b/playground/http-errors/pages/loaders/not-found.server.ts new file mode 100644 index 000000000..ea0a6be9a --- /dev/null +++ b/playground/http-errors/pages/loaders/not-found.server.ts @@ -0,0 +1,7 @@ +import { HTTPException } from 'hono/http-exception'; +import { defineHandler } from 'void/handler'; +import { statusMessages } from '../../src/utils.ts'; + +export const loader = defineHandler(() => { + throw new HTTPException(404, { message: statusMessages[404] }); +}); diff --git a/playground/http-errors/pages/loaders/not-found.tsx b/playground/http-errors/pages/loaders/not-found.tsx new file mode 100644 index 000000000..f962c7569 --- /dev/null +++ b/playground/http-errors/pages/loaders/not-found.tsx @@ -0,0 +1,3 @@ +export default function MissingPage() { + return

Not-found example

; +} diff --git a/playground/http-errors/pages/loaders/status/[status].server.ts b/playground/http-errors/pages/loaders/status/[status].server.ts new file mode 100644 index 000000000..a44e2f7a3 --- /dev/null +++ b/playground/http-errors/pages/loaders/status/[status].server.ts @@ -0,0 +1,13 @@ +import { defineHandler } from 'void/handler'; +import { statusMessages, parseStatus } from '../../../src/utils.ts'; + +export const loader = defineHandler((c) => { + const status = parseStatus(c); + c.status(status); + c.header('Retry-After', '120'); + return { + title: 'Status set in a loader', + status, + message: statusMessages[status], + }; +}); diff --git a/playground/http-errors/pages/loaders/status/[status].tsx b/playground/http-errors/pages/loaders/status/[status].tsx new file mode 100644 index 000000000..d39705d6f --- /dev/null +++ b/playground/http-errors/pages/loaders/status/[status].tsx @@ -0,0 +1 @@ +export { default } from '../../../src/status-page.tsx'; diff --git a/playground/http-errors/pages/loaders/unexpected.server.ts b/playground/http-errors/pages/loaders/unexpected.server.ts new file mode 100644 index 000000000..9ac88ecfb --- /dev/null +++ b/playground/http-errors/pages/loaders/unexpected.server.ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'void/handler'; +import { statusMessages } from '../../src/utils.ts'; + +export const loader = defineHandler(() => { + throw new Error(statusMessages[500]); +}); diff --git a/playground/http-errors/pages/loaders/unexpected.tsx b/playground/http-errors/pages/loaders/unexpected.tsx new file mode 100644 index 000000000..ec3f357b0 --- /dev/null +++ b/playground/http-errors/pages/loaders/unexpected.tsx @@ -0,0 +1,3 @@ +export default function UnexpectedPage() { + return

Unexpected error example

; +} diff --git a/playground/http-errors/pages/loaders/validated.server.ts b/playground/http-errors/pages/loaders/validated.server.ts new file mode 100644 index 000000000..c697cd8ef --- /dev/null +++ b/playground/http-errors/pages/loaders/validated.server.ts @@ -0,0 +1,11 @@ +import { defineHandler } from 'void/handler'; +import { statusMessages } from '../../src/utils.ts'; + +export const loader = defineHandler.withValidator({})((c) => { + c.status(503); + return { + title: 'Status set in a validated loader', + status: 503, + message: statusMessages[503], + }; +}); diff --git a/playground/http-errors/pages/loaders/validated.tsx b/playground/http-errors/pages/loaders/validated.tsx new file mode 100644 index 000000000..68421b3c2 --- /dev/null +++ b/playground/http-errors/pages/loaders/validated.tsx @@ -0,0 +1 @@ +export { default } from '../../src/status-page.tsx'; diff --git a/playground/http-errors/pages/middleware/application.server.ts b/playground/http-errors/pages/middleware/application.server.ts new file mode 100644 index 000000000..e91875349 --- /dev/null +++ b/playground/http-errors/pages/middleware/application.server.ts @@ -0,0 +1,8 @@ +import { defineHandler } from 'void/handler'; +import { statusMessages } from '../../src/utils.ts'; + +export const loader = defineHandler(() => ({ + title: 'Status set in application middleware', + status: 503, + message: statusMessages[503], +})); diff --git a/playground/http-errors/pages/middleware/application.tsx b/playground/http-errors/pages/middleware/application.tsx new file mode 100644 index 000000000..68421b3c2 --- /dev/null +++ b/playground/http-errors/pages/middleware/application.tsx @@ -0,0 +1 @@ +export { default } from '../../src/status-page.tsx'; diff --git a/playground/http-errors/pages/middleware/errors/[status].server.ts b/playground/http-errors/pages/middleware/errors/[status].server.ts new file mode 100644 index 000000000..b836335b4 --- /dev/null +++ b/playground/http-errors/pages/middleware/errors/[status].server.ts @@ -0,0 +1,6 @@ +import { defineHandler } from 'void/handler'; + +export const loader = defineHandler(() => { + console.log('Middleware-error page loader ran. It should have been blocked.'); + return {}; +}); diff --git a/playground/http-errors/pages/middleware/errors/[status].tsx b/playground/http-errors/pages/middleware/errors/[status].tsx new file mode 100644 index 000000000..5a5370693 --- /dev/null +++ b/playground/http-errors/pages/middleware/errors/[status].tsx @@ -0,0 +1,3 @@ +export default function MiddlewarePage() { + return

Middleware exception example

; +} diff --git a/playground/http-errors/pages/middleware/handler.server.ts b/playground/http-errors/pages/middleware/handler.server.ts new file mode 100644 index 000000000..be09cf87d --- /dev/null +++ b/playground/http-errors/pages/middleware/handler.server.ts @@ -0,0 +1,13 @@ +import { defineHandler, defineMiddleware } from 'void/handler'; +import { statusMessages } from '../../src/utils.ts'; + +const setStatus = defineMiddleware(async (c, next) => { + c.status(403); + await next(); +}); + +export const loader = defineHandler(setStatus, () => ({ + title: 'Status set in handler middleware', + status: 403, + message: statusMessages[403], +})); diff --git a/playground/http-errors/pages/middleware/handler.tsx b/playground/http-errors/pages/middleware/handler.tsx new file mode 100644 index 000000000..68421b3c2 --- /dev/null +++ b/playground/http-errors/pages/middleware/handler.tsx @@ -0,0 +1 @@ +export { default } from '../../src/status-page.tsx'; diff --git a/playground/http-errors/pages/styles.css b/playground/http-errors/pages/styles.css new file mode 100644 index 000000000..c87b64a18 --- /dev/null +++ b/playground/http-errors/pages/styles.css @@ -0,0 +1,54 @@ +.example-container { + box-sizing: border-box; + max-width: 100rem; + margin-inline: auto; + padding: 1rem; +} + +.example-container p { + max-width: 75ch; +} + +.example-page-loaded-toast { + position: fixed; + inset: 1rem auto auto 50%; + transform: translateX(-50%); + margin: 0; + padding: 0.75rem 1rem; + border: 1px solid; + border-radius: 0.25rem; + background: Canvas; + color: CanvasText; +} + +.examples { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + align-items: start; + gap: 1rem 2rem; +} + +.example { + min-width: 0; +} + +@media (max-width: 72rem) { + .examples { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} + +@media (max-width: 48rem) { + .examples { + grid-template-columns: minmax(0, 1fr); + } +} + +.example pre { + white-space: pre-wrap; +} + +.example pre, +.example code { + overflow-wrap: anywhere; +} diff --git a/playground/http-errors/src/editor-url.ts b/playground/http-errors/src/editor-url.ts new file mode 100644 index 000000000..3ecc05701 --- /dev/null +++ b/playground/http-errors/src/editor-url.ts @@ -0,0 +1,5 @@ +export function vscodeFileUrl(root: string, file: string): string { + const source = new URL(file.split('/').map(encodeURIComponent).join('/'), root); + const host = source.host ? `//${source.host}` : ''; + return `vscode://file${host}${source.pathname}:1:1`; +} diff --git a/playground/http-errors/src/source-link.tsx b/playground/http-errors/src/source-link.tsx new file mode 100644 index 000000000..8581f3084 --- /dev/null +++ b/playground/http-errors/src/source-link.tsx @@ -0,0 +1,9 @@ +import { vscodeFileUrl } from './editor-url.ts'; + +export function SourceLink({ file }: { file: string }) { + return ( + + {file} + + ); +} diff --git a/playground/http-errors/src/status-page.tsx b/playground/http-errors/src/status-page.tsx new file mode 100644 index 000000000..d2cbd5cff --- /dev/null +++ b/playground/http-errors/src/status-page.tsx @@ -0,0 +1,18 @@ +export type StatusProps = { + title: string; + status: number; + message: string; +}; + +export default function StatusPage({ title, status, message }: StatusProps) { + return ( +
+

{title}

+

{message}

+

Requested HTTP status: {status}. Check the actual response in the Network panel.

+

+ This is the normal page, not an error boundary. Setting c.status() does not abort rendering. +

+
+ ); +} diff --git a/playground/http-errors/src/utils.ts b/playground/http-errors/src/utils.ts new file mode 100644 index 000000000..e0f3205e1 --- /dev/null +++ b/playground/http-errors/src/utils.ts @@ -0,0 +1,15 @@ +import type { CloudContext } from 'void/handler'; + +export const statusMessages = { + 401: 'Please sign in', + 403: 'Your account is banned', + 404: 'This page does not exist', + 410: 'This page is permanently gone', + 429: 'Too many requests', + 500: 'Unexpected loader failure', + 503: 'Down for maintenance', +} as const + +export function parseStatus(c: CloudContext) { + return parseInt(c.req.param('status')!) as keyof typeof statusMessages; +} diff --git a/playground/http-errors/src/vite-env.d.ts b/playground/http-errors/src/vite-env.d.ts new file mode 100644 index 000000000..83d437882 --- /dev/null +++ b/playground/http-errors/src/vite-env.d.ts @@ -0,0 +1,3 @@ +/// + +declare const __HTTP_ERRORS_SOURCE_ROOT__: string; diff --git a/playground/http-errors/tsconfig.json b/playground/http-errors/tsconfig.json new file mode 100644 index 000000000..18d0c6eb8 --- /dev/null +++ b/playground/http-errors/tsconfig.json @@ -0,0 +1,16 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "noUnusedLocals": true, + "esModuleInterop": true, + "skipLibCheck": true, + "jsx": "react-jsx", + "noEmit": true, + "allowImportingTsExtensions": true + }, + "include": ["pages", "middleware", "src", "*.config.ts"] +} diff --git a/playground/http-errors/vite.config.ts b/playground/http-errors/vite.config.ts new file mode 100644 index 000000000..48c1c0d91 --- /dev/null +++ b/playground/http-errors/vite.config.ts @@ -0,0 +1,10 @@ +import { voidReact } from '@void/react/plugin'; +import { defineConfig } from 'vite-plus'; +import { voidPlugin } from 'void'; + +export default defineConfig({ + define: { + __HTTP_ERRORS_SOURCE_ROOT__: JSON.stringify(new URL('./', import.meta.url).href), + }, + plugins: [voidPlugin(), voidReact()], +}); diff --git a/playground/http-errors/void.config.ts b/playground/http-errors/void.config.ts new file mode 100644 index 000000000..83f948417 --- /dev/null +++ b/playground/http-errors/void.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'void/config'; + +export default defineConfig({ + target: 'node', + head: { + title: 'HTTP Errors Example', + htmlAttrs: { lang: 'en' }, + meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }], + }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f44dec15..ef8a9ebc1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1351,6 +1351,43 @@ importers: specifier: workspace:* version: link:../../packages/void + playground/http-errors: + dependencies: + '@hono/node-server': + specifier: 'catalog:' + version: 2.1.6(hono@4.13.17) + hono: + specifier: 'catalog:' + version: 4.13.17 + react: + specifier: 'catalog:' + version: 19.3.1 + react-dom: + specifier: 'catalog:' + version: 19.3.1(react@19.3.1) + devDependencies: + '@types/react': + specifier: ^19.3.0 + version: 19.3.0 + '@types/react-dom': + specifier: ^19.3.0 + version: 19.3.0(@types/react@19.3.0) + '@void/react': + specifier: workspace:* + version: link:../../packages/react + typescript: + specifier: 'catalog:' + version: 7.0.2 + vite: + specifier: 'catalog:' + version: '@voidzero-dev/vite-plus-core@1.6.0(@types/node@26.6.6)(esbuild@0.28.2)(jiti@2.7.0)(sass-embedded@1.104.1)(sass@1.104.1)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1)' + vite-plus: + specifier: 'catalog:' + version: 1.6.0(@astrojs/compiler-rs@0.5.2)(@types/node@26.6.6)(@vitest/ui@5.1.0)(esbuild@0.28.2)(happy-dom@21.0.4)(jiti@2.7.0)(playwright@1.65.0)(sass-embedded@1.104.1)(sass@1.104.1)(svelte@5.57.2)(terser@5.51.2)(tsx@4.23.15)(typescript@7.0.2)(yaml@2.9.1) + void: + specifier: workspace:* + version: link:../../packages/void + playground/kitchen-sink: dependencies: hono: