Repository: sveltejs/kit (js)

Issue:
Allow error pages with json bodies
### Describe the problem

I'm making a full-stack website in SvelteKit, and am now working on my API. This api is located under `/api/*`. When a user requests from an unknown URI I want to send a 404 message. As the API responds with json, I would like to respond in the same manner with the error message. However, to my knowledge, `__error.svelte` files only support html as an output.

### Describe the proposed solution

I can see two solutions

1. implementation of an `__error.json` file
2. an export in endpoints that get executed on all undefined http methods

I prefer number 2, as then I could also implement 405 Method Not Allowed errors.

### Alternatives considered

For now, I'm using an endpoint like this:

```javascript
function response() { return {
    status: 404,
    body: {
        "error": "notfound",
        "message": "Not Found"
    }
}}

// Most HTTP request methods
export const get = response;
export const post = response;
export const put = response;
export const patch = response;
export const del = response;
export const options = response;
export const head = response;
```

this is located under `src/routes/api/[...error].ts`

Although this works, it's pretty ugly imo and it doesn't cover all infinite custom http methods clients could be using.

### Importance

nice to have

### Additional Information

_No response_

Pull request description:
prefer JSON error responses over HTML in fetch case
closes #1931.

Today, if you have a route like this...

```js
// src/routes/error/+server.js
export function GET() {
  throw new Error('yikes');
}
```

...then visiting `/error` will show you the fallback error page:

<img width="201" alt="image" src="https://user-images.githubusercontent.com/1162160/187803335-6b6f178b-260f-4f1a-aa46-a0013cca8ab4.png">

If you `fetch` the page with an `application/json` header, you'll get a JSON representation of the error...

<img width="506" alt="image" src="https://user-images.githubusercontent.com/1162160/187803456-bea1fb50-5131-48a3-99e3-b96bf5ba8a96.png">

...but by default `fetch` will get HTML. In most cases that's probably not that helpful.

This PR reverses the precedence during content negotiation, so that if a request has an `accept: */*` header (which `fetch` requests do, by default) it will prefer JSON to HTML.

### Please don't delete this checklist! Before submitting the PR, please make sure you do the following:
- [x] It's really useful if your PR references an issue where it is discussed ahead of time. In many cases, features are absent for a reason. For large changes, please create an RFC: https://github.com/sveltejs/rfcs
- [x] This message body should clearly illustrate what problems it solves.
- [ ] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [ ] Run the tests with `pnpm test` and lint the project with `pnpm lint` and `pnpm check`

### Changesets
- [x] If your PR makes a change that should be noted in one or more packages' changelogs, generate a changeset by running `pnpm changeset` and following the prompts. All changesets should be `patch` until SvelteKit 1.0


Patch:
diff --git a/.changeset/odd-months-grow.md b/.changeset/odd-months-grow.md
new file mode 100644
index 000000000..d8bd476bd
--- /dev/null
+++ b/.changeset/odd-months-grow.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Prefer JSON responses when returning errors if accept header is `*/*`
diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js
index d4659cdbe..437de80e5 100644
--- a/packages/kit/src/runtime/server/utils.js
+++ b/packages/kit/src/runtime/server/utils.js
@@ -194,8 +194,8 @@ export function handle_fatal_error(event, options, error) {
 
 	// ideally we'd use sec-fetch-dest instead, but Safari — quelle surprise — doesn't support it
 	const type = negotiate(event.request.headers.get('accept') || 'text/html', [
-		'text/html',
-		'application/json'
+		'application/json',
+		'text/html'
 	]);
 
 	if (event.url.pathname.endsWith(DATA_SUFFIX) || type === 'application/json') {


Test patch:
diff --git a/packages/kit/test/apps/basics/test/server.test.js b/packages/kit/test/apps/basics/test/server.test.js
index c409a799e..f22bcd6a4 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -149,15 +149,39 @@ test.describe('Errors', () => {
 	});
 
 	test('throw error(...) in endpoint', async ({ request, read_errors }) => {
-		const res = await request.get('/errors/endpoint-throw-error');
+		// HTML
+		{
+			const res = await request.get('/errors/endpoint-throw-error', {
+				headers: {
+					accept: 'text/html'
+				}
+			});
+
+			const error = read_errors('/errors/endpoint-throw-error');
+			expect(error).toBe(undefined);
+
+			expect(res.status()).toBe(401);
+			expect(await res.text()).toContain(
+				'This is the static error page with the following message: You shall not pass'
+			);
+		}
 
-		const error = read_errors('/errors/endpoint-throw-error');
-		expect(error).toBe(undefined);
+		// JSON (default)
+		{
+			const res = await request.get('/errors/endpoint-throw-error');
 
-		expect(await res?.text()).toContain(
-			'This is the static error page with the following message: You shall not pass'
-		);
-		expect(res?.status()).toBe(401);
+			const error = read_errors('/errors/endpoint-throw-error');
+			expect(error).toBe(undefined);
+
+			expect(res.status()).toBe(401);
+			expect(await res.json()).toEqual({
+				status: 401,
+				message: 'You shall not pass',
+
+				// TODO this is gross, fix it
+				__is_http_error: true
+			});
+		}
 	});
 
 	test('throw redirect(...) in endpoint', async ({ page, read_errors }) => {
@@ -170,13 +194,38 @@ test.describe('Errors', () => {
 		expect(await page.textContent('h1')).toBe('the answer is 42');
 	});
 
-	test('error thrown in handle results in a rendered error page', async ({ request }) => {
-		const res = await request.get('/errors/error-in-handle');
+	test('error thrown in handle results in a rendered error page or JSON response', async ({
+		request
+	}) => {
+		// HTML
+		{
+			const res = await request.get('/errors/error-in-handle', {
+				headers: {
+					accept: 'text/html'
+				}
+			});
+
+			expect(res.status()).toBe(500);
+			expect(await res.text()).toContain(
+				'This is the static error page with the following message: Error in handle'
+			);
+		}
+
+		// JSON (default)
+		{
+			const res = await request.get('/errors/error-in-handle');
 
-		expect(await res.text()).toContain(
-			'This is the static error page with the following message: Error in handle'
-		);
-		expect(res.status()).toBe(500);
+			const error = await res.json();
+
+			expect(typeof error.stack).toBe('string');
+			delete error.stack;
+
+			expect(res.status()).toBe(500);
+			expect(error).toEqual({
+				name: 'Error',
+				message: 'Error in handle'
+			});
+		}
 	});
 });