Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Error handling fix #5314

Merged
merged 32 commits into from
Jul 7, 2022
Merged
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
c9c152e
fix and almost working tests
cdcarson Jun 28, 2022
48ddd8c
todo note in failing test
cdcarson Jun 28, 2022
5ecfc93
tests working
cdcarson Jun 28, 2022
bfa3dec
formatting
cdcarson Jun 28, 2022
f72ddd1
added changeset
cdcarson Jun 29, 2022
adaf85e
Merge branch 'master' into error-handling-fix
Rich-Harris Jun 29, 2022
ec052ea
Merge branch 'master' into error-handling-fix
Rich-Harris Jul 6, 2022
2e41c78
make tests more consistent with the rest of the codebase
Rich-Harris Jul 6, 2022
9d0fbcf
simplify tests
Rich-Harris Jul 6, 2022
5216d42
include stack trace, tweak tests a bit
Rich-Harris Jul 6, 2022
f7f9fc2
update changeset
Rich-Harris Jul 6, 2022
1852bbe
oh do fuck off windows
Rich-Harris Jul 6, 2022
618e16e
shuffle things around a bit, ensure handleError is called
Rich-Harris Jul 6, 2022
e4f886b
add (failing) tests for explicit errors
Rich-Harris Jul 7, 2022
30fd42f
update tests
Rich-Harris Jul 7, 2022
ec1cdde
render error page if body instanceof Error
Rich-Harris Jul 7, 2022
bb1c710
preserve errors returned from page endpoint GET handlers
Rich-Harris Jul 7, 2022
3c8360e
serialize errors consistently
Rich-Harris Jul 7, 2022
3a2127f
better error serialization
Rich-Harris Jul 7, 2022
c8b9feb
beef up tests
Rich-Harris Jul 7, 2022
2694afa
remove test.only
Rich-Harris Jul 7, 2022
75e69e1
reuse serialize_error
Rich-Harris Jul 7, 2022
3940383
shut up eslint, you big dummy
Rich-Harris Jul 7, 2022
091c09f
bah typescript
Rich-Harris Jul 7, 2022
91aa2dd
stack is already fixed
Rich-Harris Jul 7, 2022
01fdc7e
explicitly add Error to ResponseBody
Rich-Harris Jul 7, 2022
fcbb899
overhaul endpoint docs to mention Error
Rich-Harris Jul 7, 2022
ca762d8
more doc tweaks
Rich-Harris Jul 7, 2022
b19c3f5
Update packages/kit/src/runtime/server/utils.spec.js
Rich-Harris Jul 7, 2022
402ae5c
add comments
Rich-Harris Jul 7, 2022
2458f3a
Merge branch 'error-handling-fix' of github.com:cdcarson/kit into err…
Rich-Harris Jul 7, 2022
83c6252
DRY some stuff out
Rich-Harris Jul 7, 2022
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/swift-dots-cry.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@sveltejs/kit': patch
'test-basics': patch
---

Returns errors from page endpoints as JSON where appropriate
83 changes: 52 additions & 31 deletions documentation/docs/02-routing.md
Original file line number Diff line number Diff line change
@@ -51,7 +51,53 @@ A route can have multiple dynamic parameters, for example `src/routes/[category]

### Endpoints

Endpoints are modules written in `.js` (or `.ts`) files that export [request handler](/docs/types#sveltejs-kit-requesthandler) functions corresponding to HTTP methods. Their job is to make it possible to read and write data that is only available on the server (for example in a database, or on the filesystem).
Endpoints are modules written in `.js` (or `.ts`) files that export [request handler](/docs/types#sveltejs-kit-requesthandler) functions corresponding to HTTP methods. Request handlers make it possible to read and write data that is only available on the server (for example in a database, or on the filesystem).

Their job is to return a `{ status, headers, body }` object representing the response.

```js
/// file: src/routes/random.js
/** @type {import('@sveltejs/kit').RequestHandler} */
export async function get() {
return {
status: 200,
headers: {
'access-control-allow-origin': '*'
},
body: {
number: Math.random()
}
};
}
```

- `status` is an [HTTP status code](https://httpstatusdogs.com):
- `2xx` — successful response (default is `200`)
- `3xx` — redirection (should be accompanied by a `location` header)
- `4xx` — client error
- `5xx` — server error
- `headers` can either be a plain object, as above, or an instance of the [`Headers`](https://developer.mozilla.org/en-US/docs/Web/API/Headers) class
- `body` can be a plain object or, if something goes wrong, an [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error). It will be serialized as JSON

A `GET` or `HEAD` response must include a `body`, but beyond that restriction all three properties are optional.

#### Page endpoints

If an endpoint has the same filename as a page (except for the extension), the page gets its props from the endpoint — via `fetch` during client-side navigation, or via direct function call during SSR. (If a page uses syntax for [named layouts](/docs/layouts#named-layouts) or [matchers](/docs/routing#advanced-routing-matching) in its filename then the corresponding page endpoint's filename must also include them.)

For example, you might have a `src/routes/items/[id].svelte` page...

```svelte
/// file: src/routes/items/[id].svelte
<script>
// populated with data from the endpoint
export let item;
</script>
<h1>{item.title}</h1>
```

...paired with a `src/routes/items/[id].js` endpoint (don't worry about the `$lib` import, we'll get to that [later](/docs/modules#$lib)):

```js
/// file: src/routes/items/[id].js
@@ -77,6 +123,8 @@ export async function get({ params }) {

if (item) {
return {
status: 200,
headers: {},
body: { item }
};
}
@@ -87,38 +135,13 @@ export async function get({ params }) {
}
```

> Don't worry about the `$lib` import, we'll get to that [later](/docs/modules#$lib).
The type of the `get` function above comes from `./[id].d.ts`, which is a file generated by SvelteKit (inside your [`outDir`](/docs/configuration#outdir), using the [`rootDirs`](https://www.typescriptlang.org/tsconfig#rootDirs) option) that provides type safety when accessing `params`. See the section on [generated types](/docs/types#generated-types) for more detail.
> The type of the `get` function above comes from `./__types/[id].d.ts`, which is a file generated by SvelteKit (inside your [`outDir`](/docs/configuration#outdir), using the [`rootDirs`](https://www.typescriptlang.org/tsconfig#rootDirs) option) that provides type safety when accessing `params`. See the section on [generated types](/docs/types#generated-types) for more detail.
The job of a [request handler](/docs/types#sveltejs-kit-requesthandler) is to return a `{ status, headers, body }` object representing the response, where `status` is an [HTTP status code](https://httpstatusdogs.com):

- `2xx` — successful response (default is `200`)
- `3xx` — redirection (should be accompanied by a `location` header)
- `4xx` — client error
- `5xx` — server error

#### Page endpoints

If an endpoint has the same filename as a page (except for the extension), the page gets its props from the endpoint — via `fetch` during client-side navigation, or via direct function call during SSR. If a page uses syntax for [named layouts](/docs/layouts#named-layouts) or [matchers](/docs/routing#advanced-routing-matching) in its filename then the corresponding page endpoint's filename must also include them.

A page like `src/routes/items/[id].svelte` could get its props from the `body` in the endpoint above:

```svelte
/// file: src/routes/items/[id].svelte
<script>
// populated with data from the endpoint
export let item;
</script>
<h1>{item.title}</h1>
```

Because the page and route have the same URL, you will need to include an `accept: application/json` header to get JSON from the endpoint rather than HTML from the page. You can also get the raw data by appending `/__data.json` to the URL, e.g. `/items/[id]/__data.json`.
To get the raw data instead of the page, you can include an `accept: application/json` header in the request, or — for convenience — append `/__data.json` to the URL, e.g. `/items/[id]/__data.json`.

#### Standalone endpoints

Most commonly, endpoints exist to provide data to the page with which they're paired. They can, however, exist separately from pages. Standalone endpoints have slightly more flexibility over the returned `body` type — in addition to objects, they can return a `Uint8Array`.
Most commonly, endpoints exist to provide data to the page with which they're paired. They can, however, exist separately from pages. Standalone endpoints have slightly more flexibility over the returned `body` type — in addition to objects and [`Error`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error) instances, they can return a [`Uint8Array`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Uint8Array) or a [`ReadableStream`](https://developer.mozilla.org/en-US/docs/Web/API/ReadableStream).

Standalone endpoints can be given a file extension if desired, or accessed directly if not:

@@ -129,8 +152,6 @@ Standalone endpoints can be given a file extension if desired, or accessed direc
| src/routes/data/index.js | /data |
| src/routes/data.js | /data |

> Support for streaming request and response bodies is [coming soon](https://github.com/sveltejs/kit/issues/3419).
#### POST, PUT, PATCH, DELETE

Endpoints can handle any HTTP method — not just `GET` — by exporting the corresponding function:
6 changes: 5 additions & 1 deletion packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
@@ -719,7 +719,11 @@ export function create_client({ target, session, base, trailing_slash }) {
props = res.status === 204 ? {} : await res.json();
} else {
status = res.status;
error = new Error('Failed to load data');
try {
error = await res.json();
} catch (e) {
error = new Error('Failed to load data');
}
}
}

8 changes: 5 additions & 3 deletions packages/kit/src/runtime/server/endpoint.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { to_headers } from '../../utils/http.js';
import { hash } from '../hash.js';
import { is_pojo, normalize_request_method } from './utils.js';
import { is_pojo, normalize_request_method, serialize_error } from './utils.js';

/** @param {string} body */
function error(body) {
@@ -39,9 +39,10 @@ export function is_text(content_type) {
/**
* @param {import('types').RequestEvent} event
* @param {{ [method: string]: import('types').RequestHandler }} mod
* @param {import('types').SSROptions} options
* @returns {Promise<Response>}
*/
export async function render_endpoint(event, mod) {
export async function render_endpoint(event, mod, options) {
const method = normalize_request_method(event);

/** @type {import('types').RequestHandler} */
@@ -111,7 +112,8 @@ export async function render_endpoint(event, mod) {

if (is_pojo(body) && (!type || type.startsWith('application/json'))) {
headers.set('content-type', 'application/json; charset=utf-8');
normalized_body = JSON.stringify(body);
normalized_body =
body instanceof Error ? serialize_error(body, options.get_stack) : JSON.stringify(body);
} else {
normalized_body = /** @type {import('types').StrictBody} */ (body);
}
19 changes: 16 additions & 3 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
@@ -3,9 +3,10 @@ import { render_page } from './page/index.js';
import { render_response } from './page/render.js';
import { respond_with_error } from './page/respond_with_error.js';
import { coalesce_to_error } from '../../utils/error.js';
import { decode_params } from './utils.js';
import { decode_params, serialize_error } from './utils.js';
import { normalize_path } from '../../utils/url.js';
import { exec } from '../../utils/routing.js';
import { negotiate } from '../../utils/http.js';

const DATA_SUFFIX = '/__data.json';

@@ -209,7 +210,7 @@ export async function respond(request, options, state) {
let response;

if (is_data_request && route.type === 'page' && route.shadow) {
response = await render_endpoint(event, await route.shadow());
response = await render_endpoint(event, await route.shadow(), options);

// loading data for a client-side transition is a special case
if (request.headers.has('x-sveltekit-load')) {
@@ -231,7 +232,7 @@ export async function respond(request, options, state) {
} else {
response =
route.type === 'endpoint'
? await render_endpoint(event, await route.load())
? await render_endpoint(event, await route.load(), options)
: await render_page(event, route, options, state, resolve_opts);
}

@@ -315,6 +316,18 @@ export async function respond(request, options, state) {

options.handle_error(error, event);

const type = negotiate(event.request.headers.get('accept') || 'text/html', [
'text/html',
'application/json'
]);

if (is_data_request || type === 'application/json') {
return new Response(serialize_error(error, options.get_stack), {
status: 500,
headers: { 'content-type': 'application/json; charset=utf-8' }
});
}

try {
const $session = await options.hooks.getSession(event);
return await respond_with_error({
55 changes: 2 additions & 53 deletions packages/kit/src/runtime/server/page/index.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { negotiate } from '../../../utils/http.js';
import { render_endpoint } from '../endpoint.js';
import { respond } from './respond.js';

@@ -24,7 +25,7 @@ export async function render_page(event, route, options, state, resolve_opts) {
]);

if (type === 'application/json') {
return render_endpoint(event, await route.shadow());
return render_endpoint(event, await route.shadow(), options);
}
}

@@ -39,55 +40,3 @@ export async function render_page(event, route, options, state, resolve_opts) {
route
});
}

/**
* @param {string} accept
* @param {string[]} types
*/
function negotiate(accept, types) {
const parts = accept
.split(',')
.map((str, i) => {
const match = /([^/]+)\/([^;]+)(?:;q=([0-9.]+))?/.exec(str);
if (match) {
const [, type, subtype, q = '1'] = match;
return { type, subtype, q: +q, i };
}

throw new Error(`Invalid Accept header: ${accept}`);
})
.sort((a, b) => {
if (a.q !== b.q) {
return b.q - a.q;
}

if ((a.subtype === '*') !== (b.subtype === '*')) {
return a.subtype === '*' ? 1 : -1;
}

if ((a.type === '*') !== (b.type === '*')) {
return a.type === '*' ? 1 : -1;
}

return a.i - b.i;
});

let accepted;
let min_priority = Infinity;

for (const mimetype of types) {
const [type, subtype] = mimetype.split('/');
const priority = parts.findIndex(
(part) =>
(part.type === type || part.type === '*') &&
(part.subtype === subtype || part.subtype === '*')
);

if (priority !== -1 && priority < min_priority) {
accepted = mimetype;
min_priority = priority;
}
}

return accepted;
}
62 changes: 37 additions & 25 deletions packages/kit/src/runtime/server/page/load_node.js
Original file line number Diff line number Diff line change
@@ -436,22 +436,23 @@ async function load_shadow_data(route, event, options, prerender) {
};

if (!is_get) {
const result = await handler(event);

// TODO remove for 1.0
// @ts-expect-error
if (result.fallthrough) {
throw new Error(
'fallthrough is no longer supported. Use matchers instead: https://kit.svelte.dev/docs/routing#advanced-routing-matching'
);
}

const { status, headers, body } = validate_shadow_output(result);
const { status, headers, body } = validate_shadow_output(await handler(event));
add_cookies(/** @type {string[]} */ (data.cookies), headers);
data.status = status;

add_cookies(/** @type {string[]} */ (data.cookies), headers);
// explicit errors cause an error page...
if (body instanceof Error) {
if (status < 400) {
data.status = 500;
data.error = new Error('A non-error status code was returned with an error body');
} else {
data.error = body;
}

return data;
}

// Redirects are respected...
// ...redirects are respected...
if (status >= 300 && status < 400) {
data.redirect = /** @type {string} */ (
headers instanceof Headers ? headers.get('location') : headers.location
@@ -467,20 +468,21 @@ async function load_shadow_data(route, event, options, prerender) {

const get = (method === 'head' && mod.head) || mod.get;
if (get) {
const result = await get(event);

// TODO remove for 1.0
// @ts-expect-error
if (result.fallthrough) {
throw new Error(
'fallthrough is no longer supported. Use matchers instead: https://kit.svelte.dev/docs/routing#advanced-routing-matching'
);
}

const { status, headers, body } = validate_shadow_output(result);
const { status, headers, body } = validate_shadow_output(await get(event));
add_cookies(/** @type {string[]} */ (data.cookies), headers);
data.status = status;

if (body instanceof Error) {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could refactor this into a reusable function since the other new code added here does the same thing

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

feels like unnecessary indirection to me — you add function call overhead, a new utility somewhere in the codebase, an import declaration per consuming module... you shave off a few characters at the callsite but make the codebase larger and arguably less easy to read

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ah, you're talking about the whole block, not just the highlighted line. thought you mean is_error(body)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is one of those cases where you basically can't DRY it out, because of the control flow (i.e. mutating an existing object and conditionally returning it). Or rather you can, but the resulting function is larger than the code you saved by deduplicating. i reckon it's probably not worth it

if (status < 400) {
data.status = 500;
data.error = new Error('A non-error status code was returned with an error body');
} else {
data.error = body;
}

return data;
}

if (status >= 400) {
data.error = new Error('Failed to load data');
return data;
@@ -527,6 +529,14 @@ function add_cookies(target, headers) {
* @param {import('types').ShadowEndpointOutput} result
*/
function validate_shadow_output(result) {
// TODO remove for 1.0
// @ts-expect-error
if (result.fallthrough) {
throw new Error(
'fallthrough is no longer supported. Use matchers instead: https://kit.svelte.dev/docs/routing#advanced-routing-matching'
);
}

const { status = 200, body = {} } = result;
let headers = result.headers || {};

@@ -541,7 +551,9 @@ function validate_shadow_output(result) {
}

if (!is_pojo(body)) {
throw new Error('Body returned from endpoint request handler must be a plain object');
throw new Error(
'Body returned from endpoint request handler must be a plain object or an Error'
);
}

return { status, headers, body };
19 changes: 2 additions & 17 deletions packages/kit/src/runtime/server/page/render.js
Original file line number Diff line number Diff line change
@@ -6,6 +6,7 @@ import { render_json_payload_script } from '../../../utils/escape.js';
import { s } from '../../../utils/misc.js';
import { Csp, csp_ready } from './csp.js';
import { PrerenderingURL } from '../../../utils/url.js';
import { serialize_error } from '../utils.js';

// TODO rename this function/module

@@ -179,7 +180,7 @@ export async function render_response({
trailing_slash: ${s(options.trailing_slash)},
hydrate: ${resolve_opts.ssr && page_config.hydrate ? `{
status: ${status},
error: ${serialize_error(error)},
error: ${error && serialize_error(error, e => e.stack)},
nodes: [${branch.map(({ node }) => node.index).join(', ')}],
params: ${devalue(event.params)},
routeId: ${s(event.routeId)}
@@ -325,19 +326,3 @@ function try_serialize(data, fail) {
return null;
}
}

// Ensure we return something truthy so the client will not re-render the page over the error

/** @param {(Error & {frame?: string} & {loc?: object}) | undefined | null} error */
function serialize_error(error) {
if (!error) return null;
let serialized = try_serialize(error);
if (!serialized) {
const { name, message, stack } = error;
serialized = try_serialize({ ...error, name, message, stack });
}
if (!serialized) {
serialized = '{}';
}
return serialized;
}
41 changes: 41 additions & 0 deletions packages/kit/src/runtime/server/utils.js
Original file line number Diff line number Diff line change
@@ -55,3 +55,44 @@ export function normalize_request_method(event) {
const method = event.request.method.toLowerCase();
return method === 'delete' ? 'del' : method; // 'delete' is a reserved word
}

/**
* Serialize an error into a JSON string, by copying its `name`, `message`
* and (in dev) `stack`, plus any custom properties, plus recursively
* serialized `cause` properties. This is necessary because
* `JSON.stringify(error) === '{}'`
* @param {Error} error
* @param {(error: Error) => string | undefined} get_stack
*/
export function serialize_error(error, get_stack) {
return JSON.stringify(clone_error(error, get_stack));
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it could use a comment explaining why we're doing a clone

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

}

/**
* @param {Error} error
* @param {(error: Error) => string | undefined} get_stack
*/
function clone_error(error, get_stack) {
const {
name,
message,
// this should constitute 'using' a var, since it affects `custom`
// eslint-disable-next-line
stack,
// @ts-expect-error i guess typescript doesn't know about error.cause yet
cause,
...custom
} = error;

/** @type {Record<string, any>} */
const object = { name, message, stack: get_stack(error) };

if (cause) object.cause = clone_error(cause, get_stack);

for (const key in custom) {
// @ts-expect-error
object[key] = custom[key];
}

return object;
}
42 changes: 41 additions & 1 deletion packages/kit/src/runtime/server/utils.spec.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { test } from 'uvu';
import * as assert from 'uvu/assert';
import { lowercase_keys } from './utils.js';
import { lowercase_keys, serialize_error } from './utils.js';

test('lowercase_keys', () => {
assert.equal(lowercase_keys({ KEY: 'value' }), { key: 'value' });
@@ -9,4 +9,44 @@ test('lowercase_keys', () => {
assert.equal(lowercase_keys({ 1: 'Hello World' }), { 1: 'Hello World' });
});

test('serialize_error', () => {
class FancyError extends Error {
name = 'FancyError';
fancy = true;

/**
* @param {string} message
* @param {{
* cause?: Error
* }} [options]
*/
constructor(message, options) {
// @ts-expect-error go home typescript ur drunk
super(message, options);
}
}

const error = new FancyError('something went wrong', {
cause: new Error('sorry')
});

const serialized = serialize_error(error, (error) => error.stack);

assert.equal(
serialized,
JSON.stringify({
name: 'FancyError',
message: 'something went wrong',
stack: error.stack,
cause: {
name: 'Error',
message: 'sorry',
// @ts-expect-error
stack: error.cause.stack
},
fancy: true
})
);
});

test.run();
54 changes: 54 additions & 0 deletions packages/kit/src/utils/http.js
Original file line number Diff line number Diff line change
@@ -19,3 +19,57 @@ export function to_headers(object) {

return headers;
}

/**
* Given an Accept header and a list of possible content types, pick
* the most suitable one to respond with
* @param {string} accept
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might not hurt to add descriptions for these two. I guess it's the accept header and types that we're okay using or something?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

added

* @param {string[]} types
*/
export function negotiate(accept, types) {
const parts = accept
.split(',')
.map((str, i) => {
const match = /([^/]+)\/([^;]+)(?:;q=([0-9.]+))?/.exec(str);
if (match) {
const [, type, subtype, q = '1'] = match;
return { type, subtype, q: +q, i };
}

throw new Error(`Invalid Accept header: ${accept}`);
})
.sort((a, b) => {
if (a.q !== b.q) {
return b.q - a.q;
}

if ((a.subtype === '*') !== (b.subtype === '*')) {
return a.subtype === '*' ? 1 : -1;
}

if ((a.type === '*') !== (b.type === '*')) {
return a.type === '*' ? 1 : -1;
}

return a.i - b.i;
});

let accepted;
let min_priority = Infinity;

for (const mimetype of types) {
const [type, subtype] = mimetype.split('/');
const priority = parts.findIndex(
(part) =>
(part.type === type || part.type === '*') &&
(part.subtype === subtype || part.subtype === '*')
);

if (priority !== -1 && priority < min_priority) {
accepted = mimetype;
min_priority = priority;
}
}

return accepted;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<script>
import { page } from '$app/stores';
</script>

<pre>{JSON.stringify(
{
status: $page.status,
name: $page.error.name,
message: $page.error.message,
stack: $page.error.stack,
// @ts-expect-error
fancy: $page.error.fancy
},
null,
' '
)}</pre>
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export class FancyError extends Error {
name = 'FancyError';
fancy = true;

constructor(message, options) {
super(message, options);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { FancyError } from './_shared.js';

export const get = () => ({
status: 400,
body: new FancyError('oops')
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>if you see this something went wrong</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { FancyError } from './_shared.js';

export const get = () => {
throw new FancyError('oops');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>if you see this something went wrong</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<a id="get-implicit" href="/errors/page-endpoint/get-implicit">GET (implicit)</a>
<a id="get-explicit" href="/errors/page-endpoint/get-explicit">GET (explicit)</a>

<form action="/errors/page-endpoint/post-implicit" method="post">
<button type="submit" id="post-implicit">POST (implicit)</button>
</form>

<form action="/errors/page-endpoint/post-explicit" method="post">
<button type="submit" id="post-explicit">POST (explicit)</button>
</form>
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import { FancyError } from './_shared.js';

export const post = () => ({
status: 400,
body: new FancyError('oops')
});
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>if you see this something went wrong</h1>
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { FancyError } from './_shared.js';

export const post = () => {
throw new FancyError('oops');
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
<h1>if you see this something went wrong</h1>
133 changes: 133 additions & 0 deletions packages/kit/test/apps/basics/test/test.js
Original file line number Diff line number Diff line change
@@ -1133,6 +1133,139 @@ test.describe('Errors', () => {
);
});

test('page endpoint GET thrown error message is preserved', async ({
page,
clicknav,
read_errors
}) => {
await page.goto('/errors/page-endpoint');
await clicknav('#get-implicit');
const json = await page.textContent('pre');
const { status, name, message, stack, fancy } = JSON.parse(json);

expect(status).toBe(500);
expect(name).toBe('FancyError');
expect(message).toBe('oops');
expect(fancy).toBe(true);

if (process.env.DEV) {
const lines = stack.split('\n');
expect(lines[1]).toContain('get-implicit.js:4:8');
}

const error = read_errors('/errors/page-endpoint/get-implicit');
expect(error).toContain('oops');
});

test('page endpoint GET returned error message is preserved', async ({
page,
clicknav,
read_errors
}) => {
await page.goto('/errors/page-endpoint');
await clicknav('#get-explicit');
const json = await page.textContent('pre');
const { status, name, message, stack, fancy } = JSON.parse(json);

expect(status).toBe(400);
expect(name).toBe('FancyError');
expect(message).toBe('oops');
expect(fancy).toBe(true);

if (process.env.DEV) {
const lines = stack.split('\n');
expect(lines[1]).toContain('get-explicit.js:5:8');
}

const error = read_errors('/errors/page-endpoint/get-explicit');
expect(error).toBe(undefined);
});

test('page endpoint POST thrown error message is preserved', async ({ page, read_errors }) => {
// The case where we're submitting a POST request via a form.
// It should show the __error template with our message.
await page.goto('/errors/page-endpoint');
await Promise.all([page.waitForNavigation(), page.click('#post-implicit')]);
const json = await page.textContent('pre');
const { status, name, message, stack, fancy } = JSON.parse(json);

expect(status).toBe(500);
expect(name).toBe('FancyError');
expect(message).toBe('oops');
expect(fancy).toBe(true);

if (process.env.DEV) {
const lines = stack.split('\n');
expect(lines[1]).toContain('post-implicit.js:4:8');
}

const error = read_errors('/errors/page-endpoint/post-implicit');
expect(error).toContain('oops');
});

test('page endpoint POST returned error message is preserved', async ({ page, read_errors }) => {
// The case where we're submitting a POST request via a form.
// It should show the __error template with our message.
await page.goto('/errors/page-endpoint');
await Promise.all([page.waitForNavigation(), page.click('#post-explicit')]);
const json = await page.textContent('pre');
const { status, name, message, stack, fancy } = JSON.parse(json);

expect(status).toBe(400);
expect(name).toBe('FancyError');
expect(message).toBe('oops');
expect(fancy).toBe(true);

if (process.env.DEV) {
const lines = stack.split('\n');
expect(lines[1]).toContain('post-explicit.js:5:8');
}

const error = read_errors('/errors/page-endpoint/post-explicit');
expect(error).toBe(undefined);
});

test('page endpoint thrown error respects `accept: application/json`', async ({ request }) => {
const response = await request.get('/errors/page-endpoint/get-implicit', {
headers: {
accept: 'application/json'
}
});

const { message, name, stack, fancy } = await response.json();

expect(response.status()).toBe(500);
expect(name).toBe('FancyError');
expect(message).toBe('oops');
expect(fancy).toBe(true);

if (process.env.DEV) {
expect(stack.split('\n').length).toBeGreaterThan(1);
} else {
expect(stack.split('\n').length).toBe(1);
}
});

test('page endpoint returned error respects `accept: application/json`', async ({ request }) => {
const response = await request.get('/errors/page-endpoint/get-explicit', {
headers: {
accept: 'application/json'
}
});

const { message, name, stack } = await response.json();

expect(response.status()).toBe(400);
expect(name).toBe('FancyError');
expect(message).toBe('oops');

if (process.env.DEV) {
expect(stack.split('\n').length).toBeGreaterThan(1);
} else {
expect(stack.split('\n').length).toBe(1);
}
});

test('returns 400 when accessing a malformed URI', async ({ page, javaScriptEnabled }) => {
if (javaScriptEnabled) {
// the JS tests will look for body.started which won't be present
2 changes: 1 addition & 1 deletion packages/kit/types/index.d.ts
Original file line number Diff line number Diff line change
@@ -267,7 +267,7 @@ export interface ResolveOptions {
transformPage?: ({ html }: { html: string }) => MaybePromise<string>;
}

export type ResponseBody = JSONValue | Uint8Array | ReadableStream;
export type ResponseBody = JSONValue | Uint8Array | ReadableStream | Error;

export class Server {
constructor(manifest: SSRManifest);
10 changes: 9 additions & 1 deletion sites/kit.svelte.dev/src/lib/docs/client/docs.css
Original file line number Diff line number Diff line change
@@ -205,13 +205,21 @@
background: rgba(255, 62, 0, 0.1) !important;
}

.content ul ul {
margin-bottom: 0;
}

.content ul > li {
margin: 0.5em 0;
}

/** hacky overrides to allow filenames inside code blocks —
TODO change the CSS in site-kit so we can get rid of this */
.code-block {
background-color: var(--code-bg);
color: var(--code-base);
border-radius: 0.5rem;
margin: 0 0 1rem 0;
margin: 0 0 2rem 0;
font-size: 14px;
max-width: var(--linemax);
box-shadow: inset 1px 1px 6px hsla(205.7, 63.6%, 30.8%, 0.06);