Skip to content
Merged
Show file tree
Hide file tree
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
Expand Up @@ -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
Expand All @@ -77,6 +123,8 @@ export async function get({ params }) {

if (item) {
return {
status: 200,
headers: {},
body: { item }
};
}
Expand All @@ -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:

Expand All @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion packages/kit/src/runtime/client/client.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
}
}
}

Expand Down
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) {
Expand Down Expand Up @@ -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} */
Expand Down Expand Up @@ -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);
}
Expand Down
19 changes: 16 additions & 3 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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')) {
Expand All @@ -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);
}

Expand Down Expand Up @@ -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({
Expand Down
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';

Expand All @@ -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);
}
}

Expand All @@ -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
Expand Up @@ -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
Expand All @@ -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;
Expand Down Expand Up @@ -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 || {};

Expand All @@ -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 };
Expand Down
Loading