Repository: sveltejs/kit
Pull request #6468 (2022-08-31 13:52:41)

tidy up data serialization
Related to #6461 

* simplifies data serialization
* prevents `validation_errors` from being serialized twice (once as a `<script>`, once by `devalue`)
* uses `data-` attributes so that we generate compliant 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.
- [x] Ideally, include a test that fails without this PR but passes with it.

### Tests
- [x] 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


changeset-bot[bot]: ###  🦋  Changeset detected

Latest commit: 5997b2cddd178285a71546550b64b443da2a152b

**The changes in this PR will be included in the next version bump.**

<details><summary>This PR includes changesets to release 1 package</summary>

  | Name          | Type  |
| ------------- | ----- |
| @sveltejs/kit | Patch |

</details>

Not sure what this means? [Click here  to learn what changesets are](https://github.com/changesets/changesets/blob/master/docs/adding-a-changeset.md).

[Click here if you're a maintainer who wants to add another changeset to this PR](https://github.com/sveltejs/kit/new/tidy-up-data-serialization?filename=.changeset/sweet-garlics-help.md&value=---%0A%22%40sveltejs%2Fkit%22%3A%20patch%0A%22test-basics%22%3A%20patch%0A---%0A%0Atidy%20up%20data%20serialization%0A)



Patch:
diff --git a/.changeset/cold-cycles-report.md b/.changeset/cold-cycles-report.md
new file mode 100644
index 000000000..d6acd2c45
--- /dev/null
+++ b/.changeset/cold-cycles-report.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Prevent validation_errors from being serialized twice
diff --git a/.changeset/few-ghosts-attend.md b/.changeset/few-ghosts-attend.md
new file mode 100644
index 000000000..3c51929f6
--- /dev/null
+++ b/.changeset/few-ghosts-attend.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+[breaking] catch and render raw response when unexpected error occurs in endpoint
diff --git a/.changeset/odd-sloths-sit.md b/.changeset/odd-sloths-sit.md
new file mode 100644
index 000000000..38749baaa
--- /dev/null
+++ b/.changeset/odd-sloths-sit.md
@@ -0,0 +1,5 @@
+---
+'@sveltejs/kit': patch
+---
+
+Print error if resolveOpts.ssr is set
diff --git a/.changeset/pre.json b/.changeset/pre.json
index d96656044..c02e810d2 100644
--- a/.changeset/pre.json
+++ b/.changeset/pre.json
@@ -268,6 +268,7 @@
     "few-buses-argue",
     "few-carrots-sell",
     "few-cobras-switch",
+    "few-ghosts-attend",
     "few-hornets-joke",
     "few-points-taste",
     "few-rockets-sort",
@@ -681,6 +682,7 @@
     "odd-ligers-march",
     "odd-ligers-swim",
     "odd-pumas-grow",
+    "odd-sloths-sit",
     "odd-tools-add",
     "odd-trees-visit",
     "old-beers-push",
diff --git a/packages/kit/CHANGELOG.md b/packages/kit/CHANGELOG.md
index 1c07dfe05..1ec91b806 100644
--- a/packages/kit/CHANGELOG.md
+++ b/packages/kit/CHANGELOG.md
@@ -1,5 +1,17 @@
 # @sveltejs/kit
 
+## 1.0.0-next.458
+
+### Patch Changes
+
+- [breaking] catch and render raw response when unexpected error occurs in endpoint ([#6434](https://github.com/sveltejs/kit/pull/6434))
+
+## 1.0.0-next.457
+
+### Patch Changes
+
+- Print error if resolveOpts.ssr is set ([#6475](https://github.com/sveltejs/kit/pull/6475))
+
 ## 1.0.0-next.456
 
 ### Patch Changes
diff --git a/packages/kit/package.json b/packages/kit/package.json
index 7dcb69a5f..0db208949 100644
--- a/packages/kit/package.json
+++ b/packages/kit/package.json
@@ -1,6 +1,6 @@
 {
 	"name": "@sveltejs/kit",
-	"version": "1.0.0-next.456",
+	"version": "1.0.0-next.458",
 	"repository": {
 		"type": "git",
 		"url": "https://github.com/sveltejs/kit",
diff --git a/packages/kit/src/runtime/client/fetcher.js b/packages/kit/src/runtime/client/fetcher.js
index e806f9c0a..97ee337dc 100644
--- a/packages/kit/src/runtime/client/fetcher.js
+++ b/packages/kit/src/runtime/client/fetcher.js
@@ -44,10 +44,10 @@ if (import.meta.env.DEV) {
 export function initial_fetch(resource, opts) {
 	const url = JSON.stringify(typeof resource === 'string' ? resource : resource.url);
 
-	let selector = `script[sveltekit\\:data-type="data"][sveltekit\\:data-url=${url}]`;
+	let selector = `script[data-sveltekit-fetched][data-url=${url}]`;
 
 	if (opts && typeof opts.body === 'string') {
-		selector += `[sveltekit\\:data-body="${hash(opts.body)}"]`;
+		selector += `[data-hash="${hash(opts.body)}"]`;
 	}
 
 	const script = document.querySelector(selector);
diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js
index 9585e027e..6eebd7c71 100644
--- a/packages/kit/src/runtime/server/endpoint.js
+++ b/packages/kit/src/runtime/server/endpoint.js
@@ -1,4 +1,4 @@
-import { HttpError, Redirect } from '../control.js';
+import { Redirect } from '../control.js';
 import { check_method_names, method_not_allowed } from './utils.js';
 
 /**
@@ -39,9 +39,8 @@ export async function render_endpoint(event, mod, state) {
 		);
 
 		if (!(response instanceof Response)) {
-			return new Response(
-				`Invalid response from route ${event.url.pathname}: handler should return a Response object`,
-				{ status: 500 }
+			throw new Error(
+				`Invalid response from route ${event.url.pathname}: handler should return a Response object`
 			);
 		}
 
@@ -52,15 +51,13 @@ export async function render_endpoint(event, mod, state) {
 
 		return response;
 	} catch (error) {
-		if (error instanceof HttpError) {
-			return new Response(error.message, { status: error.status });
-		} else if (error instanceof Redirect) {
+		if (error instanceof Redirect) {
 			return new Response(undefined, {
 				status: error.status,
-				headers: { Location: error.location }
+				headers: { location: error.location }
 			});
-		} else {
-			throw error;
 		}
+
+		throw error;
 	}
 }
diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js
index d9cf65be4..413903640 100644
--- a/packages/kit/src/runtime/server/index.js
+++ b/packages/kit/src/runtime/server/index.js
@@ -3,10 +3,9 @@ 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 { serialize_error, GENERIC_ERROR, static_error_page } from './utils.js';
+import { GENERIC_ERROR, handle_fatal_error } from './utils.js';
 import { decode_params, disable_search, normalize_path } from '../../utils/url.js';
 import { exec } from '../../utils/routing.js';
-import { negotiate } from '../../utils/http.js';
 import { render_data } from './data/index.js';
 import { DATA_SUFFIX } from '../../constants.js';
 
@@ -190,178 +189,151 @@ export async function respond(request, options, state) {
 		transformPageChunk: default_transform
 	};
 
-	// TODO match route before calling handle?
-
-	try {
-		const response = await options.hooks.handle({
-			event,
-			resolve: async (event, opts) => {
-				if (opts) {
-					// TODO remove for 1.0
-					// @ts-expect-error
-					if (opts.transformPage) {
-						throw new Error(
-							'transformPage has been replaced by transformPageChunk — see https://github.com/sveltejs/kit/pull/5657 for more information'
-						);
-					}
-					// @ts-expect-error
-					if (opts.ssr) {
-						throw new Error(
-							'ssr has been removed, set it in the appropriate +layout.js instead. See the PR for more information: https://github.com/sveltejs/kit/pull/6197'
-						);
-					}
-
-					resolve_opts = {
-						transformPageChunk: opts.transformPageChunk || default_transform
-					};
+	/**
+	 *
+	 * @param {import('types').RequestEvent} event
+	 * @param {import('types').ResolveOptions} [opts]
+	 */
+	async function resolve(event, opts) {
+		try {
+			if (opts) {
+				// TODO remove for 1.0
+				if ('transformPage' in opts) {
+					throw new Error(
+						'transformPage has been replaced by transformPageChunk — see https://github.com/sveltejs/kit/pull/5657 for more information'
+					);
 				}
 
-				if (state.prerendering?.fallback) {
-					return await render_response({
-						event,
-						options,
-						state,
-						page_config: { ssr: false, csr: true },
-						status: 200,
-						error: null,
-						branch: [],
-						fetched: [],
-						validation_errors: undefined,
-						cookies: [],
-						resolve_opts
-					});
+				if ('ssr' in opts) {
+					throw new Error(
+						'ssr has been removed, set it in the appropriate +layout.js instead. See the PR for more information: https://github.com/sveltejs/kit/pull/6197'
+					);
 				}
 
-				if (route) {
-					/** @type {Response} */
-					let response;
-
-					if (is_data_request) {
-						response = await render_data(event, route, options, state);
-					} else if (route.page) {
-						response = await render_page(event, route, route.page, options, state, resolve_opts);
-					} else if (route.endpoint) {
-						response = await render_endpoint(event, await route.endpoint(), state);
-					} else {
-						// a route will always have a page or an endpoint, but TypeScript
-						// doesn't know that
-						throw new Error('This should never happen');
-					}
+				resolve_opts = {
+					transformPageChunk: opts.transformPageChunk || default_transform
+				};
+			}
 
-					if (!is_data_request) {
-						// we only want to set cookies on __data.js requests, we don't
-						// want to cache stuff erroneously etc
-						for (const key in headers) {
-							const value = headers[key];
-							response.headers.set(key, /** @type {string} */ (value));
-						}
-					}
+			if (state.prerendering?.fallback) {
+				return await render_response({
+					event,
+					options,
+					state,
+					page_config: { ssr: false, csr: true },
+					status: 200,
+					error: null,
+					branch: [],
+					fetched: [],
+					validation_errors: undefined,
+					cookies: [],
+					resolve_opts
+				});
+			}
+
+			if (route) {
+				/** @type {Response} */
+				let response;
 
-					for (const cookie of cookies) {
-						response.headers.append('set-cookie', cookie);
+				if (is_data_request) {
+					response = await render_data(event, route, options, state);
+				} else if (route.page) {
+					response = await render_page(event, route, route.page, options, state, resolve_opts);
+				} else if (route.endpoint) {
+					response = await render_endpoint(event, await route.endpoint(), state);
+				} else {
+					// a route will always have a page or an endpoint, but TypeScript
+					// doesn't know that
+					throw new Error('This should never happen');
+				}
+
+				if (!is_data_request) {
+					// we only want to set cookies on __data.js requests, we don't
+					// want to cache stuff erroneously etc
+					for (const key in headers) {
+						const value = headers[key];
+						response.headers.set(key, /** @type {string} */ (value));
 					}
+				}
 
-					// respond with 304 if etag matches
-					if (response.status === 200 && response.headers.has('etag')) {
-						let if_none_match_value = request.headers.get('if-none-match');
+				for (const cookie of cookies) {
+					response.headers.append('set-cookie', cookie);
+				}
 
-						// ignore W/ prefix https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#directives
-						if (if_none_match_value?.startsWith('W/"')) {
-							if_none_match_value = if_none_match_value.substring(2);
-						}
+				// respond with 304 if etag matches
+				if (response.status === 200 && response.headers.has('etag')) {
+					let if_none_match_value = request.headers.get('if-none-match');
 
-						const etag = /** @type {string} */ (response.headers.get('etag'));
+					// ignore W/ prefix https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/If-None-Match#directives
+					if (if_none_match_value?.startsWith('W/"')) {
+						if_none_match_value = if_none_match_value.substring(2);
+					}
 
-						if (if_none_match_value === etag) {
-							const headers = new Headers({ etag });
+					const etag = /** @type {string} */ (response.headers.get('etag'));
 
-							// https://datatracker.ietf.org/doc/html/rfc7232#section-4.1
-							for (const key of ['cache-control', 'content-location', 'date', 'expires', 'vary']) {
-								const value = response.headers.get(key);
-								if (value) headers.set(key, value);
-							}
+					if (if_none_match_value === etag) {
+						const headers = new Headers({ etag });
 
-							return new Response(undefined, {
-								status: 304,
-								headers
-							});
+						// https://datatracker.ietf.org/doc/html/rfc7232#section-4.1
+						for (const key of ['cache-control', 'content-location', 'date', 'expires', 'vary']) {
+							const value = response.headers.get(key);
+							if (value) headers.set(key, value);
 						}
-					}
 
-					return response;
+						return new Response(undefined, {
+							status: 304,
+							headers
+						});
+					}
 				}
 
-				if (state.initiator === GENERIC_ERROR) {
-					return new Response('Internal Server Error', {
-						status: 500
-					});
-				}
+				return response;
+			}
 
-				// if this request came direct from the user, rather than
-				// via a `fetch` in a `load`, render a 404 page
-				if (!state.initiator) {
-					return await respond_with_error({
-						event,
-						options,
-						state,
-						status: 404,
-						error: new Error(`Not found: ${event.url.pathname}`),
-						resolve_opts
-					});
-				}
+			if (state.initiator === GENERIC_ERROR) {
+				return new Response('Internal Server Error', {
+					status: 500
+				});
+			}
 
-				if (state.prerendering) {
-					return new Response('not found', { status: 404 });
-				}
+			// if this request came direct from the user, rather than
+			// via a `fetch` in a `load`, render a 404 page
+			if (!state.initiator) {
+				return await respond_with_error({
+					event,
+					options,
+					state,
+					status: 404,
+					error: new Error(`Not found: ${event.url.pathname}`),
+					resolve_opts
+				});
+			}
 
-				// we can't load the endpoint from our own manifest,
-				// so we need to make an actual HTTP request
-				return await fetch(request);
-			},
+			if (state.prerendering) {
+				return new Response('not found', { status: 404 });
+			}
 
+			// we can't load the endpoint from our own manifest,
+			// so we need to make an actual HTTP request
+			return await fetch(request);
+		} catch (e) {
+			const error = coalesce_to_error(e);
+			return handle_fatal_error(event, options, error);
+		}
+	}
+
+	try {
+		return await options.hooks.handle({
+			event,
+			resolve,
 			// TODO remove for 1.0
 			// @ts-expect-error
 			get request() {
 				throw new Error('request in handle has been replaced with event' + details);
 			}
 		});
-
-		// TODO for 1.0, change the error message to point to docs rather than PR
-		if (response && !(response instanceof Response)) {
-			throw new Error('handle must return a Response object' + details);
-		}
-
-		return response;
 	} catch (/** @type {unknown} */ e) {
 		const error = coalesce_to_error(e);
-
-		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' }
-			});
-		}
-
-		// TODO is this necessary? should we just return a plain 500 at this point?
-		try {
-			return await respond_with_error({
-				event,
-				options,
-				state,
-				status: 500,
-				error,
-				resolve_opts
-			});
-		} catch (/** @type {unknown} */ e) {
-			const error = coalesce_to_error(e);
-			return static_error_page(options, 500, error.message);
-		}
+		return handle_fatal_error(event, options, error);
 	}
 }
diff --git a/packages/kit/src/runtime/server/page/fetch.js b/packages/kit/src/runtime/server/page/fetch.js
index efef5f7b3..adde5f994 100644
--- a/packages/kit/src/runtime/server/page/fetch.js
+++ b/packages/kit/src/runtime/server/page/fetch.js
@@ -192,6 +192,7 @@ export function create_fetch({ event, options, state, route, prerender_default }
 				async function text() {
 					const body = await response.text();
 
+					// TODO just pass `response.headers`, for processing inside `serialize_data`
 					/** @type {import('types').ResponseHeaders} */
 					const headers = {};
 					for (const [key, value] of response.headers) {
diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js
index 1d05961f9..c704cdc83 100644
--- a/packages/kit/src/runtime/server/page/index.js
+++ b/packages/kit/src/runtime/server/page/index.js
@@ -334,7 +334,7 @@ export async function render_page(event, route, page, options, state, resolve_op
 		});
 	} catch (error) {
 		// if we end up here, it means the data loaded successfull
-		// but the page failed to render
+		// but the page failed to render, or that a prerendering error occurred
 		options.handle_error(/** @type {Error} */ (error), event);
 
 		return await respond_with_error({
diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js
index 4ca30dc50..9e69c3dcd 100644
--- a/packages/kit/src/runtime/server/page/render.js
+++ b/packages/kit/src/runtime/server/page/render.js
@@ -2,7 +2,7 @@ import { devalue } from 'devalue';
 import { readable, writable } from 'svelte/store';
 import * as cookie from 'cookie';
 import { hash } from '../../hash.js';
-import { render_json_payload_script } from '../../../utils/escape.js';
+import { serialize_data } from './serialize_data.js';
 import { s } from '../../../utils/misc.js';
 import { Csp } from './csp.js';
 import { serialize_error } from '../utils.js';
@@ -284,25 +284,7 @@ export async function render_response({
 	}
 
 	if (page_config.ssr && page_config.csr) {
-		/** @type {string[]} */
-		const serialized_data = [];
-
-		for (const { url, body, response } of fetched) {
-			serialized_data.push(
-				render_json_payload_script(
-					{ type: 'data', url, body: typeof body === 'string' ? hash(body) : undefined },
-					response
-				)
-			);
-		}
-
-		if (validation_errors) {
-			serialized_data.push(
-				render_json_payload_script({ type: 'validation_errors' }, validation_errors)
-			);
-		}
-
-		body += `\n\t${serialized_data.join('\n\t')}`;
+		body += `\n\t${fetched.map(serialize_data).join('\n\t')}`;
 	}
 
 	if (options.service_worker) {
diff --git a/packages/kit/src/runtime/server/page/serialize_data.js b/packages/kit/src/runtime/server/page/serialize_data.js
new file mode 100644
index 000000000..f32922bb5
--- /dev/null
+++ b/packages/kit/src/runtime/server/page/serialize_data.js
@@ -0,0 +1,58 @@
+import { escape_html_attr } from '../../../utils/escape.js';
+import { hash } from '../../hash.js';
+
+/**
+ * Inside a script element, only `</script` and `<!--` hold special meaning to the HTML parser.
+ *
+ * The first closes the script element, so everything after is treated as raw HTML.
+ * The second disables further parsing until `-->`, so the script element might be unexpectedly
+ * kept open until until an unrelated HTML comment in the page.
+ *
+ * U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are escaped for the sake of pre-2018
+ * browsers.
+ *
+ * @see tests for unsafe parsing examples.
+ * @see https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
+ * @see https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-state
+ * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-state
+ * @see https://github.com/tc39/proposal-json-superset
+ * @type {Record<string, string>}
+ */
+const replacements = {
+	'<': '\\u003C',
+	'\u2028': '\\u2028',
+	'\u2029': '\\u2029'
+};
+
+const pattern = new RegExp(`[${Object.keys(replacements).join('')}]`, 'g');
+
+/**
+ * Generates a raw HTML string containing a safe script element carrying data and associated attributes.
+ *
+ * It escapes all the special characters needed to guarantee the element is unbroken, but care must
+ * be taken to ensure it is inserted in the document at an acceptable position for a script element,
+ * and that the resulting string isn't further modified.
+ *
+ * @param {import('./types.js').Fetched} fetched
+ * @returns {string} The raw HTML of a script element carrying the JSON payload.
+ * @example const html = serialize_data('/data.json', null, { foo: 'bar' });
+ */
+export function serialize_data(fetched) {
+	const safe_payload = JSON.stringify(fetched.response).replace(
+		pattern,
+		(match) => replacements[match]
+	);
+
+	const attrs = [
+		'type="application/json"',
+		'data-sveltekit-fetched',
+		`data-url=${escape_html_attr(fetched.url)}`
+	];
+
+	if (fetched.body) {
+		attrs.push(`data-hash=${escape_html_attr(hash(fetched.body))}`);
+	}
+
+	return `<script ${attrs.join(' ')}>${safe_payload}</script>`;
+}
diff --git a/packages/kit/src/runtime/server/page/serialize_data.spec.js b/packages/kit/src/runtime/server/page/serialize_data.spec.js
new file mode 100644
index 000000000..9906ba429
--- /dev/null
+++ b/packages/kit/src/runtime/server/page/serialize_data.spec.js
@@ -0,0 +1,59 @@
+import { test } from 'uvu';
+import * as assert from 'uvu/assert';
+import { serialize_data } from './serialize_data.js';
+
+test('escapes slashes', () => {
+	assert.equal(
+		serialize_data({
+			url: 'foo',
+			body: null,
+			response: {
+				status: 200,
+				statusText: 'OK',
+				headers: {},
+				body: '</script><script>alert("xss")'
+			}
+		}),
+		'<script type="application/json" data-sveltekit-fetched data-url="foo">' +
+			'{"status":200,"statusText":"OK","headers":{},"body":"\\u003C/script>\\u003Cscript>alert(\\"xss\\")"}' +
+			'</script>'
+	);
+});
+
+test('escapes exclamation marks', () => {
+	assert.equal(
+		serialize_data({
+			url: 'foo',
+			body: null,
+			response: {
+				status: 200,
+				statusText: 'OK',
+				headers: {},
+				body: '<!--</script>...-->alert("xss")'
+			}
+		}),
+		'<script type="application/json" data-sveltekit-fetched data-url="foo">' +
+			'{"status":200,"statusText":"OK","headers":{},"body":"\\u003C!--\\u003C/script>...-->alert(\\"xss\\")"}' +
+			'</script>'
+	);
+});
+
+test('escapes the attribute values', () => {
+	const raw = 'an "attr" & a \ud800';
+	const escaped = 'an &quot;attr&quot; &amp; a &#55296;';
+	assert.equal(
+		serialize_data({
+			url: raw,
+			body: null,
+			response: {
+				status: 200,
+				statusText: 'OK',
+				headers: {},
+				body: ''
+			}
+		}),
+		`<script type="application/json" data-sveltekit-fetched data-url="${escaped}">{"status":200,"statusText":"OK","headers":{},"body":\"\"}</script>`
+	);
+});
+
+test.run();
diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js
index 26c173ad8..d4659cdbe 100644
--- a/packages/kit/src/runtime/server/utils.js
+++ b/packages/kit/src/runtime/server/utils.js
@@ -1,4 +1,6 @@
 import { devalue } from 'devalue';
+import { DATA_SUFFIX } from '../../constants.js';
+import { negotiate } from '../../utils/http.js';
 import { HttpError } from '../control.js';
 
 /** @param {any} body */
@@ -175,3 +177,33 @@ export function static_error_page(options, status, message) {
 		status
 	});
 }
+
+/**
+ * @param {import('types').RequestEvent} event
+ * @param {import('types').SSROptions} options
+ * @param {Error} error
+ */
+export function handle_fatal_error(event, options, error) {
+	let status = 500;
+
+	if (error instanceof HttpError) {
+		status = error.status;
+	} else {
+		options.handle_error(error, event);
+	}
+
+	// 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'
+	]);
+
+	if (event.url.pathname.endsWith(DATA_SUFFIX) || type === 'application/json') {
+		return new Response(serialize_error(error, options.get_stack), {
+			status,
+			headers: { 'content-type': 'application/json; charset=utf-8' }
+		});
+	}
+
+	return static_error_page(options, status, error.message);
+}
diff --git a/packages/kit/src/utils/escape.js b/packages/kit/src/utils/escape.js
index 959a16067..543e1a13c 100644
--- a/packages/kit/src/utils/escape.js
+++ b/packages/kit/src/utils/escape.js
@@ -1,61 +1,3 @@
-/**
- * Inside a script element, only `</script` and `<!--` hold special meaning to the HTML parser.
- *
- * The first closes the script element, so everything after is treated as raw HTML.
- * The second disables further parsing until `-->`, so the script element might be unexpectedly
- * kept open until until an unrelated HTML comment in the page.
- *
- * U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are escaped for the sake of pre-2018
- * browsers.
- *
- * @see tests for unsafe parsing examples.
- * @see https://html.spec.whatwg.org/multipage/scripting.html#restrictions-for-contents-of-script-elements
- * @see https://html.spec.whatwg.org/multipage/syntax.html#cdata-rcdata-restrictions
- * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-state
- * @see https://html.spec.whatwg.org/multipage/parsing.html#script-data-double-escaped-state
- * @see https://github.com/tc39/proposal-json-superset
- * @type {Record<string, string>}
- */
-const render_json_payload_script_dict = {
-	'<': '\\u003C',
-	'\u2028': '\\u2028',
-	'\u2029': '\\u2029'
-};
-
-const render_json_payload_script_regex = new RegExp(
-	`[${Object.keys(render_json_payload_script_dict).join('')}]`,
-	'g'
-);
-
-/**
- * Generates a raw HTML string containing a safe script element carrying JSON data and associated attributes.
- *
- * It escapes all the special characters needed to guarantee the element is unbroken, but care must
- * be taken to ensure it is inserted in the document at an acceptable position for a script element,
- * and that the resulting string isn't further modified.
- *
- * Attribute names must be type-checked so we don't need to escape them.
- *
- * @param {import('types').PayloadScriptAttributes} attrs A list of attributes to be added to the element.
- * @param {any} payload The data to be carried by the element. Must be serializable to JSON.
- * @returns {string} The raw HTML of a script element carrying the JSON payload.
- * @example const html = render_json_payload_script({ type: 'data', url: '/data.json' }, { foo: 'bar' });
- */
-export function render_json_payload_script(attrs, payload) {
-	const safe_payload = JSON.stringify(payload).replace(
-		render_json_payload_script_regex,
-		(match) => render_json_payload_script_dict[match]
-	);
-
-	let safe_attrs = '';
-	for (const [key, value] of Object.entries(attrs)) {
-		if (value === undefined) continue;
-		safe_attrs += ` sveltekit:data-${key}=${escape_html_attr(value)}`;
-	}
-
-	return `<script type="application/json"${safe_attrs}>${safe_payload}</script>`;
-}
-
 /**
  * When inside a double-quoted attribute value, only `&` and `"` hold special meaning.
  * @see https://html.spec.whatwg.org/multipage/parsing.html#attribute-value-(double-quoted)-state
diff --git a/packages/kit/src/utils/escape.spec.js b/packages/kit/src/utils/escape.spec.js
index 57eef069c..4c6585280 100644
--- a/packages/kit/src/utils/escape.spec.js
+++ b/packages/kit/src/utils/escape.spec.js
@@ -1,52 +1,15 @@
-import { suite } from 'uvu';
+import { test } from 'uvu';
 import * as assert from 'uvu/assert';
-import { render_json_payload_script, escape_html_attr } from './escape.js';
+import { escape_html_attr } from './escape.js';
 
-const json = suite('render_json_payload_script');
-
-json('escapes slashes', () => {
-	// The type here doesn't really matter for the purposes of escaping,
-	// but we want to avoid upsetting TypeScript.
-	assert.equal(
-		render_json_payload_script({ type: 'validation_errors' }, [
-			{ unsafe: '</script><script>alert("xss")' }
-		]),
-		'<script type="application/json" sveltekit:data-type="validation_errors">' +
-			'[{"unsafe":"\\u003C/script>\\u003Cscript>alert(\\"xss\\")"}]' +
-			'</script>'
-	);
-});
-
-json('escapes exclamation marks', () => {
-	assert.equal(
-		render_json_payload_script({ type: 'validation_errors' }, [
-			{ '<!--</script>...-->alert("xss")': 'unsafe' }
-		]),
-		'<script type="application/json" sveltekit:data-type="validation_errors">' +
-			'[{"\\u003C!--\\u003C/script>...-->alert(\\"xss\\")":"unsafe"}]' +
-			'</script>'
-	);
-});
-
-json('escapes the attribute values', () => {
-	const raw = 'an "attr" & a \ud800';
-	const escaped = 'an &quot;attr&quot; &amp; a &#55296;';
-	assert.equal(
-		render_json_payload_script({ type: 'data', url: raw }, {}),
-		`<script type="application/json" sveltekit:data-type="data" sveltekit:data-url="${escaped}">{}</script>`
-	);
-});
-
-const attr = suite('escape_html_attr');
-
-attr('escapes special attribute characters', () => {
+test('escape_html_attr escapes special attribute characters', () => {
 	assert.equal(
 		escape_html_attr('some "values" are &special here, <others> aren\'t.'),
 		'"some &quot;values&quot; are &amp;special here, <others> aren\'t."'
 	);
 });
 
-attr('escapes invalid surrogates', () => {
+test('escape_html_attr escapes invalid surrogates', () => {
 	assert.equal(escape_html_attr('\ud800\udc00'), '"\ud800\udc00"');
 	assert.equal(escape_html_attr('\ud800'), '"&#55296;"');
 	assert.equal(escape_html_attr('\udc00'), '"&#56320;"');
@@ -56,5 +19,4 @@ attr('escapes invalid surrogates', () => {
 	assert.equal(escape_html_attr('\ud800\ud800\udc00\udc00'), '"&#55296;\ud800\udc00&#56320;"');
 });
 
-json.run();
-attr.run();
+test.run();
diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts
index c5669e786..234af54f8 100644
--- a/packages/kit/types/internal.d.ts
+++ b/packages/kit/types/internal.d.ts
@@ -135,10 +135,6 @@ export interface PageNode {
 	child_pages?: PageNode[];
 }
 
-export type PayloadScriptAttributes =
-	| { type: 'data'; url: string; body?: string }
-	| { type: 'validation_errors' };
-
 export interface PrerenderDependency {
 	response: Response;
 	body: null | string | Uint8Array;


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 1cafac3ba..c409a799e 100644
--- a/packages/kit/test/apps/basics/test/server.test.js
+++ b/packages/kit/test/apps/basics/test/server.test.js
@@ -148,17 +148,19 @@ test.describe('Errors', () => {
 		);
 	});
 
-	test('throw error(..) in endpoint', async ({ page, read_errors }) => {
-		const res = await page.goto('/errors/endpoint-throw-error');
+	test('throw error(...) in endpoint', async ({ request, read_errors }) => {
+		const res = await request.get('/errors/endpoint-throw-error');
 
 		const error = read_errors('/errors/endpoint-throw-error');
 		expect(error).toBe(undefined);
 
-		expect(await res?.text()).toBe('You shall not pass');
+		expect(await res?.text()).toContain(
+			'This is the static error page with the following message: You shall not pass'
+		);
 		expect(res?.status()).toBe(401);
 	});
 
-	test('throw redirect(..) in endpoint', async ({ page, read_errors }) => {
+	test('throw redirect(...) in endpoint', async ({ page, read_errors }) => {
 		const res = await page.goto('/errors/endpoint-throw-redirect');
 		expect(res?.status()).toBe(200); // redirects are opaque to the browser
 
@@ -167,6 +169,15 @@ 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');
+
+		expect(await res.text()).toContain(
+			'This is the static error page with the following message: Error in handle'
+		);
+		expect(res.status()).toBe(500);
+	});
 });
 
 test.describe('Load', () => {
diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js
index 6a9bc4cf0..6e9eb5652 100644
--- a/packages/kit/test/apps/basics/test/test.js
+++ b/packages/kit/test/apps/basics/test/test.js
@@ -537,16 +537,6 @@ test.describe('Errors', () => {
 		);
 	});
 
-	test('error thrown in handle results in a rendered error page', async ({ page }) => {
-		await page.goto('/errors/error-in-handle');
-
-		expect(await page.textContent('footer')).toBe('Custom layout');
-		expect(await page.textContent('#message')).toBe(
-			'This is your custom error page saying: "Error in handle"'
-		);
-		expect(await page.innerHTML('h1')).toBe('500');
-	});
-
 	test('prerendering a page with a mutative page endpoint results in a catchable error', async ({
 		page
 	}) => {
@@ -673,7 +663,7 @@ test.describe('Load', () => {
 
 		if (!javaScriptEnabled) {
 			// by the time JS has run, hydration will have nuked these scripts
-			const script_contents = await page.innerHTML('script[sveltekit\\:data-type="data"]');
+			const script_contents = await page.innerHTML('script[data-sveltekit-fetched]');
 
 			const payload =
 				'{"status":200,"statusText":"","headers":{"content-type":"application/json"},"body":"{\\"answer\\":42}"}';
@@ -703,11 +693,11 @@ test.describe('Load', () => {
 		if (!javaScriptEnabled) {
 			// by the time JS has run, hydration will have nuked these scripts
 			const script_contents_a = await page.innerHTML(
-				'script[sveltekit\\:data-type="data"][sveltekit\\:data-url="/load/serialization-post.json"][sveltekit\\:data-body="3t25"]'
+				'script[data-sveltekit-fetched][data-url="/load/serialization-post.json"][data-hash="3t25"]'
 			);
 
 			const script_contents_b = await page.innerHTML(
-				'script[sveltekit\\:data-type="data"][sveltekit\\:data-url="/load/serialization-post.json"][sveltekit\\:data-body="3t24"]'
+				'script[data-sveltekit-fetched][data-url="/load/serialization-post.json"][data-hash="3t24"]'
 			);
 
 			expect(script_contents_a).toBe(payload_a);