diff --git a/.changeset/ripe-tires-chew.md b/.changeset/ripe-tires-chew.md new file mode 100644 index 000000000000..17a8c85a9fd4 --- /dev/null +++ b/.changeset/ripe-tires-chew.md @@ -0,0 +1,6 @@ +--- +"@sveltejs/kit": major +--- + +breaking: allow `handleError` to influence status code + \ No newline at end of file diff --git a/documentation/docs/30-advanced/25-errors.md b/documentation/docs/30-advanced/25-errors.md index 81b615e38691..da300b6fb0aa 100644 --- a/documentation/docs/30-advanced/25-errors.md +++ b/documentation/docs/30-advanced/25-errors.md @@ -100,6 +100,27 @@ By default, unexpected errors are printed to the console (or, in production, you Unexpected errors will go through the [`handleError`](hooks#Shared-hooks-handleError) hook, where you can add your own error handling — for example, sending errors to a reporting service, or returning a custom error object which becomes `page.error`. +You can override the HTTP status code used in the response by returning a `status` property: + +```js +/// file: src/hooks.server.js +// Assuming you have this ... +class NotFound extends Error {} + +/** @type {import('@sveltejs/kit').HandleServerError} */ +export function handleError({ error, event, status, message }) { + // ... you can do this + if (error instanceof NotFound) { + return { + status: 404, + message: 'Not found' + }; + } + + return { message: 'Something went wrong' }; +} +``` + ## Rendering errors Ordinarily, if an error happens during server-side rendering (for example inside a component's ` diff --git a/packages/kit/test/apps/basics/src/routes/errors/handle-error-status/+page.js b/packages/kit/test/apps/basics/src/routes/errors/handle-error-status/+page.js new file mode 100644 index 000000000000..25d076279a1d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/errors/handle-error-status/+page.js @@ -0,0 +1,4 @@ +/** @type {import('@sveltejs/kit').Load} */ +export async function load() { + throw new Error('Status override test'); +} diff --git a/packages/kit/test/apps/basics/test/cross-platform/test.js b/packages/kit/test/apps/basics/test/cross-platform/test.js index 9caab5539ab0..d93c31efc723 100644 --- a/packages/kit/test/apps/basics/test/cross-platform/test.js +++ b/packages/kit/test/apps/basics/test/cross-platform/test.js @@ -336,6 +336,16 @@ test.describe('Errors', () => { expect(await page.textContent('#error-layout-data')).toBe('42'); }); + test('handleError can override the status code of an unexpected error', async ({ page }) => { + const response = await page.goto('/errors/handle-error-status'); + + await expect(page.locator('h1')).toHaveText('404'); + await expect(page.locator('#message')).toHaveText( + 'This is your custom error page saying: "Status override test (500 Internal Error)"' + ); + expect(/** @type {Response} */ (response).status()).toBe(404); + }); + test('error in endpoint', async ({ page, read_errors }) => { const res = await page.goto('/errors/endpoint'); diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js index 8e4d76ebb4aa..23b4bc679d2b 100644 --- a/packages/kit/test/apps/basics/test/server.test.js +++ b/packages/kit/test/apps/basics/test/server.test.js @@ -576,7 +576,8 @@ test.describe('Errors', () => { expect(res.status()).toBe(401); expect(await res.json()).toEqual({ - message: 'You shall not pass' + message: 'You shall not pass', + status: 401 }); } }); @@ -610,7 +611,8 @@ test.describe('Errors', () => { error: { message: process.env.DEV ? 'POST method not allowed. No form actions exist for the page at /errors/missing-actions (405 Method Not Allowed)' - : 'POST method not allowed. No form actions exist for this page (405 Method Not Allowed)' + : 'POST method not allowed. No form actions exist for this page (405 Method Not Allowed)', + status: 405 } }); }); @@ -641,7 +643,8 @@ test.describe('Errors', () => { expect(error.stack).toBe(undefined); expect(res.status()).toBe(500); expect(error).toEqual({ - message: 'Error in handle (500 Internal Error)' + message: 'Error in handle (500 Internal Error)', + status: 500 }); } }); @@ -672,7 +675,8 @@ test.describe('Errors', () => { expect(error.stack).toBe(undefined); expect(res.status()).toBe(500); expect(error).toEqual({ - message: 'Expected error in handle' + message: 'Expected error in handle', + status: 500 }); } }); diff --git a/packages/kit/test/types/app-error-enhanced/error.test.ts b/packages/kit/test/types/app-error-enhanced/error.test.ts new file mode 100644 index 000000000000..29877e626c60 --- /dev/null +++ b/packages/kit/test/types/app-error-enhanced/error.test.ts @@ -0,0 +1,67 @@ +import { error, type HandleClientError, type HandleServerError } from '@sveltejs/kit'; + +const app_error: App.Error = { status: 500, message: 'Unexpected error', additional: true }; + +declare global { + namespace App { + interface Error { + additional: boolean; + } + } +} + +// @ts-expect-error App.Error requires status +const app_error_without_status: App.Error = { message: 'Unexpected error' }; + +const handle_error_hooks: [ + HandleServerError, + HandleServerError, + HandleClientError, + HandleClientError +] = [ + () => ({ message: 'Unexpected error', additional: true }), + // @ts-expect-error App.Error requires additional + () => ({ message: 'Unexpected error' }), + () => ({ message: 'Unexpected error', additional: true }), + // @ts-expect-error App.Error requires + () => ({ message: 'Unexpected error' }) +]; + +void app_error; +void app_error_without_status; +void handle_error_hooks; + +function a() { + // @ts-expect-error App.Error requires additional + error(400, 'Bad request'); +} + +function b() { + // @ts-expect-error App.Error requires additional + error(400, { message: 'Bad request' }); +} + +function c() { + error(400, 'Bad request', { additional: true }); +} + +function d() { + // @ts-expect-error + error(400, { message: 'Bad request' }); +} + +function e() { + error(400, { message: 'Bad request', additional: true }); +} + +function f() { + // @ts-expect-error + error(400); +} + +a; +b; +c; +d; +e; +f; diff --git a/packages/kit/test/types/app-error-enhanced/tsconfig.json b/packages/kit/test/types/app-error-enhanced/tsconfig.json new file mode 100644 index 000000000000..cccbcf5d4ea7 --- /dev/null +++ b/packages/kit/test/types/app-error-enhanced/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "noEmit": true, + "paths": { + "@sveltejs/kit": ["../../../types/index.d.ts"] + } + }, + "include": ["**/*.test.ts", "../../../types/index.d.ts"] +} diff --git a/packages/kit/test/types/error.test.ts b/packages/kit/test/types/error.test.ts new file mode 100644 index 000000000000..8724a3cf1888 --- /dev/null +++ b/packages/kit/test/types/error.test.ts @@ -0,0 +1,48 @@ +import { error, type HandleClientError, type HandleServerError } from '@sveltejs/kit'; + +const app_error: App.Error = { status: 500, message: 'Unexpected error' }; + +// @ts-expect-error App.Error requires status +const app_error_without_status: App.Error = { message: 'Unexpected error' }; + +const handle_error_hooks: [HandleServerError, HandleClientError] = [ + () => ({ message: 'Unexpected error' }), + () => ({ message: 'Unexpected error' }) +]; + +void app_error; +void app_error_without_status; +void handle_error_hooks; + +function a() { + error(400, 'Bad request'); +} + +function b() { + error(400, { message: 'Bad request' }); +} + +function c() { + // @ts-expect-error + error(400, 'Bad request', { cause: new Error('cause') }); +} + +function d() { + error(400, { message: 'Bad request' }); +} + +function e() { + // @ts-expect-error + error(400, { message: 'Bad request', cause: new Error('cause') }); +} + +function f() { + error(400); +} + +a; +b; +c; +d; +e; +f; diff --git a/packages/kit/test/types/tsconfig.json b/packages/kit/test/types/tsconfig.json index 7675e28d941c..9773e7d707f1 100644 --- a/packages/kit/test/types/tsconfig.json +++ b/packages/kit/test/types/tsconfig.json @@ -3,5 +3,6 @@ "compilerOptions": { "noEmit": true }, - "include": ["**/*.test.ts", "../../src/types/*.d.ts"] + "include": ["**/*.test.ts", "../../src/types/*.d.ts"], + "exclude": ["../../**/write_types/test/**", "./app-error-enhanced/**"] } diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index d5fc1126b55a..3582653f0590 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -8,6 +8,8 @@ declare module '@sveltejs/kit' { // @ts-ignore this is an optional peer dependency so could be missing. Written like this so dts-buddy preserves the ts-ignore type Span = import('@opentelemetry/api').Span; + type AppErrorWithOptionalStatus = Omit & { status?: App.Error['status'] }; + /** * [Adapters](https://svelte.dev/docs/kit/adapters) are responsible for taking the production build and turning it into something that can be deployed to a platform of your choosing. */ @@ -924,13 +926,16 @@ declare module '@sveltejs/kit' { * * If an unexpected error is thrown during loading or rendering, this function will be called with the error and the event. * Make sure that this function _never_ throws an error. + * + * The returned object can include a `status` property to override the HTTP status code used in the response. + * If omitted, the status defaults to 500. */ export type HandleServerError = (input: { error: unknown; event: RequestEvent; status: number; message: string; - }) => MaybePromise; + }) => MaybePromise; /** * The [`handleValidationError`](https://svelte.dev/docs/kit/hooks#Server-hooks-handleValidationError) hook runs when the argument to a remote function fails validation. @@ -938,20 +943,23 @@ declare module '@sveltejs/kit' { * It will be called with the validation issues and the event, and must return an object shape that matches `App.Error`. */ export type HandleValidationError = - (input: { issues: Issue[]; event: RequestEvent }) => MaybePromise; + (input: { issues: Issue[]; event: RequestEvent }) => MaybePromise; /** * The client-side [`handleError`](https://svelte.dev/docs/kit/hooks#Shared-hooks-handleError) hook runs when an unexpected error is thrown while navigating. * * If an unexpected error is thrown during loading or the following render, this function will be called with the error and the event. * Make sure that this function _never_ throws an error. + * + * The returned object can include a `status` property to override the HTTP status code used in the response. + * If omitted, the status defaults to 500. */ export type HandleClientError = (input: { error: unknown; event: NavigationEvent; status: number; message: string; - }) => MaybePromise; + }) => MaybePromise; /** * The [`handleFetch`](https://svelte.dev/docs/kit/hooks#Server-hooks-handleFetch) hook allows you to modify (or replace) the result of an [`event.fetch`](https://svelte.dev/docs/kit/load#Making-fetch-requests) call that runs on the server (or during prerendering) inside an endpoint, `load`, `action`, `handle`, `handleError` or `reroute`. @@ -1807,7 +1815,7 @@ declare module '@sveltejs/kit' { | { type: 'success'; status: number; data?: Success } | { type: 'failure'; status: number; data?: Failure } | { type: 'redirect'; status: number; location: string } - | { type: 'error'; status?: number; error: any }; + | { type: 'error'; status?: number; error: App.Error }; /** * The object returned by the [`error`](https://svelte.dev/docs/kit/@sveltejs-kit#error) function. @@ -2859,20 +2867,38 @@ declare module '@sveltejs/kit' { * @throws {import('./public.js').HttpError} This error instructs SvelteKit to initiate HTTP error handling. * @throws {Error} If the provided status is invalid (not between 400 and 599). */ - export function error(status: number, body: App.Error): never; + export function error(status: number, body: Omit & { + status?: App.Error["status"]; + }): never; /** * Throws an error with a HTTP status code and an optional message. * When called during request handling, this will cause SvelteKit to * return an error response without invoking `handleError`. * Make sure you're not catching the thrown error, which would prevent SvelteKit from handling it. * @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses). Must be in the range 400-599. - * @param body An object that conforms to the App.Error type. If a string is passed, it will be used as the message property. + * @param body The error message. * @throws {import('./public.js').HttpError} This error instructs SvelteKit to initiate HTTP error handling. * @throws {Error} If the provided status is invalid (not between 400 and 599). */ - export function error(status: number, body?: { + export function error(status: number, body: { + status: number; message: string; - } extends App.Error ? App.Error | string | undefined : never): never; + } extends App.Error ? string | void | undefined : never): never; + /** + * Throws an error with a HTTP status code and an optional message. + * When called during request handling, this will cause SvelteKit to + * return an error response without invoking `handleError`. + * Make sure you're not catching the thrown error, which would prevent SvelteKit from handling it. + * @param status The [HTTP status code](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status#client_error_responses). Must be in the range 400-599. + * @param body The error message. + * @param properties Additional properties of the App.Error type. + * @throws {import('./public.js').HttpError} This error instructs SvelteKit to initiate HTTP error handling. + * @throws {Error} If the provided status is invalid (not between 400 and 599). + */ + export function error(status: number, body: string, properties: { + status: number; + message: string; + } extends App.Error ? never : Omit): never; /** * Checks whether this is an error thrown by {@link error}. * @param status The status to filter for. @@ -3796,6 +3822,7 @@ declare namespace App { * Defines the common shape of expected and unexpected errors. Expected errors are thrown using the `error` function. Unexpected errors are handled by the `handleError` hooks which should return this shape. */ export interface Error { + status: number; message: string; }