diff --git a/.changeset/quiet-camels-shop.md b/.changeset/quiet-camels-shop.md new file mode 100644 index 000000000000..065fbdf5fb97 --- /dev/null +++ b/.changeset/quiet-camels-shop.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[breaking] add `error.html` page, rename `kit.config.files.template` to `kit.config.files.appTemplate` diff --git a/documentation/docs/01-project-structure.md b/documentation/docs/01-project-structure.md index 737c5b9f8da1..d93e514fd012 100644 --- a/documentation/docs/01-project-structure.md +++ b/documentation/docs/01-project-structure.md @@ -14,6 +14,7 @@ my-project/ │ ├ routes/ │ │ └ [your routes] │ ├ app.html +│ ├ error.html │ └ hooks.js ├ static/ │ └ [your static assets] @@ -41,6 +42,9 @@ The `src` directory contains the meat of your project. - `%sveltekit.body%` — the markup for a rendered page. Typically this lives inside a `
` or other element, rather than directly inside ``, to prevent bugs caused by browser extensions injecting elements that are then destroyed by the hydration process - `%sveltekit.assets%` — either [`paths.assets`](/docs/configuration#paths), if specified, or a relative path to [`paths.base`](/docs/configuration#base) - `%sveltekit.nonce%` — a [CSP](/docs/configuration#csp) nonce for manually included links and scripts, if used +- `error.html` (optional) is the page that is rendered when everything else fails. It can contain the following placeholders: + - `%sveltekit.status%` — the HTTP status + - `%sveltekit.message%` — the error message - `hooks.js` (optional) contains your application's [hooks](/docs/hooks) - `service-worker.js` (optional) contains your [service worker](/docs/service-workers) diff --git a/documentation/docs/03-routing.md b/documentation/docs/03-routing.md index 5dff407cc72e..03468fa3308b 100644 --- a/documentation/docs/03-routing.md +++ b/documentation/docs/03-routing.md @@ -190,7 +190,7 @@ If an error occurs during `load`, SvelteKit will render a default error page. Yo

{$page.status}: {$page.error.message}

``` -SvelteKit will 'walk up the tree' looking for the closest error boundary — if the file above didn't exist it would try `src/routes/blog/+error.svelte` and `src/routes/+error.svelte` before rendering the default error page. +SvelteKit will 'walk up the tree' looking for the closest error boundary — if the file above didn't exist it would try `src/routes/blog/+error.svelte` and `src/routes/+error.svelte` before rendering the default error page. If _that_ fails, SvelteKit will bail out and render a static fallback error page, which you can customise by creating a `src/error.html` file. ### +layout diff --git a/documentation/docs/15-configuration.md b/documentation/docs/15-configuration.md index 6b39c660ea14..81f858280a11 100644 --- a/documentation/docs/15-configuration.md +++ b/documentation/docs/15-configuration.md @@ -40,7 +40,8 @@ const config = { params: 'src/params', routes: 'src/routes', serviceWorker: 'src/service-worker', - template: 'src/app.html' + appTemplate: 'src/app.html', + errorTemplate: 'src/error.html' }, inlineStyleThreshold: 0, methodOverride: { diff --git a/packages/kit/src/core/config/default-error.html b/packages/kit/src/core/config/default-error.html new file mode 100644 index 000000000000..eae4dbc89673 --- /dev/null +++ b/packages/kit/src/core/config/default-error.html @@ -0,0 +1,56 @@ + + + + + %sveltekit.message% + + + + +
+ %sveltekit.status% +
+

%sveltekit.message%

+
+
+ + diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index f903bb086b05..3f5d53dc4f11 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -10,11 +10,11 @@ import options from './options.js'; * @param {import('types').ValidatedConfig} config */ export function load_template(cwd, config) { - const { template } = config.kit.files; - const relative = path.relative(cwd, template); + const { appTemplate } = config.kit.files; + const relative = path.relative(cwd, appTemplate); - if (fs.existsSync(template)) { - const contents = fs.readFileSync(template, 'utf8'); + if (fs.existsSync(appTemplate)) { + const contents = fs.readFileSync(appTemplate, 'utf8'); // TODO remove this for 1.0 const match = /%svelte\.([a-z]+)%/.exec(contents); @@ -34,7 +34,17 @@ export function load_template(cwd, config) { throw new Error(`${relative} does not exist`); } - return fs.readFileSync(template, 'utf-8'); + return fs.readFileSync(appTemplate, 'utf-8'); +} + +/** + * Loads the error page (src/error.html by default) if it exists. + * Falls back to a generic error page content. + * @param {import('types').ValidatedConfig} config + */ +export function load_error_page(config) { + const { errorTemplate } = config.kit.files; + return fs.readFileSync(errorTemplate, 'utf-8'); } /** @@ -64,10 +74,19 @@ function process_config(config, { cwd = process.cwd() } = {}) { validated.kit.outDir = path.resolve(cwd, validated.kit.outDir); for (const key in validated.kit.files) { + // TODO remove for 1.0 + if (key === 'template') continue; + // @ts-expect-error this is typescript at its stupidest validated.kit.files[key] = path.resolve(cwd, validated.kit.files[key]); } + if (!fs.existsSync(validated.kit.files.errorTemplate)) { + validated.kit.files.errorTemplate = url.fileURLToPath( + new URL('./default-error.html', import.meta.url) + ); + } + return validated; } diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index aa748ec0a92a..da3274f2d384 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -84,7 +84,9 @@ const get_defaults = (prefix = '') => ({ params: join(prefix, 'src/params'), routes: join(prefix, 'src/routes'), serviceWorker: join(prefix, 'src/service-worker'), - template: join(prefix, 'src/app.html') + appTemplate: join(prefix, 'src/app.html'), + errorTemplate: join(prefix, 'src/error.html'), + template: undefined }, headers: undefined, host: undefined, @@ -381,6 +383,9 @@ test('load default config (esm)', async () => { const defaults = get_defaults(cwd + '/'); defaults.kit.version.name = config.kit.version.name; + defaults.kit.files.errorTemplate = fileURLToPath( + new URL('./default-error.html', import.meta.url) + ); assert.equal(config, defaults); }); diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index d460596a10e8..4a619faca6b6 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -140,7 +140,12 @@ const options = object( params: string(join('src', 'params')), routes: string(join('src', 'routes')), serviceWorker: string(join('src', 'service-worker')), - template: string(join('src', 'app.html')) + appTemplate: string(join('src', 'app.html')), + errorTemplate: string(join('src', 'error.html')), + // TODO: remove this for the 1.0 release + template: error( + () => 'config.kit.files.template has been renamed to config.kit.files.appTemplate' + ) }), // TODO: remove this for the 1.0 release diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index 4b9782a7b963..7518965e43c7 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { mkdirp, posixify } from '../../../utils/filesystem.js'; import { get_vite_config, merge_vite_configs, resolve_entry } from '../utils.js'; -import { load_template } from '../../../core/config/index.js'; +import { load_error_page, load_template } from '../../../core/config/index.js'; import { runtime_directory } from '../../../core/utils.js'; import { create_build, find_deps, get_default_build_config, is_http_method } from './utils.js'; import { s } from '../../../utils/misc.js'; @@ -14,9 +14,10 @@ import { s } from '../../../utils/misc.js'; * has_service_worker: boolean; * runtime: string; * template: string; + * error_page: string; * }} opts */ -const server_template = ({ config, hooks, has_service_worker, runtime, template }) => ` +const server_template = ({ config, hooks, has_service_worker, runtime, template, error_page }) => ` import root from '__GENERATED__/root.svelte'; import { respond } from '${runtime}/server/index.js'; import { set_paths, assets, base } from '${runtime}/paths.js'; @@ -24,12 +25,16 @@ import { set_prerendering } from '${runtime}/env.js'; import { set_private_env } from '${runtime}/env-private.js'; import { set_public_env } from '${runtime}/env-public.js'; -const template = ({ head, body, assets, nonce }) => ${s(template) +const app_template = ({ head, body, assets, nonce }) => ${s(template) .replace('%sveltekit.head%', '" + head + "') .replace('%sveltekit.body%', '" + body + "') .replace(/%sveltekit\.assets%/g, '" + assets + "') .replace(/%sveltekit\.nonce%/g, '" + nonce + "')}; +const error_template = ({ status, message }) => ${s(error_page) + .replace(/%sveltekit\.status%/g, '" + status + "') + .replace(/%sveltekit\.message%/g, '" + message + "')}; + let read = null; set_paths(${s(config.kit.paths)}); @@ -78,8 +83,9 @@ export class Server { root, service_worker: ${has_service_worker ? "base + '/service-worker.js'" : 'null'}, router: ${s(config.kit.browser.router)}, - template, - template_contains_nonce: ${template.includes('%sveltekit.nonce%')}, + app_template, + app_template_contains_nonce: ${template.includes('%sveltekit.nonce%')}, + error_template, trailing_slash: ${s(config.kit.trailingSlash)} }; } @@ -205,7 +211,8 @@ export async function build_server(options, client) { hooks: app_relative(hooks_file), has_service_worker: config.kit.serviceWorker.register && !!service_worker_entry_file, runtime: posixify(path.relative(build_dir, runtime_directory)), - template: load_template(cwd, config) + template: load_template(cwd, config), + error_page: load_error_page(config) }) ); diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index a74cdddd20ed..cbefff2fb7cc 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -7,7 +7,7 @@ import { getRequest, setResponse } from '../../../exports/node/index.js'; import { installPolyfills } from '../../../exports/node/polyfills.js'; import { coalesce_to_error } from '../../../utils/error.js'; import { posixify } from '../../../utils/filesystem.js'; -import { load_template } from '../../../core/config/index.js'; +import { load_error_page, load_template } from '../../../core/config/index.js'; import { SVELTE_KIT_ASSETS } from '../../../constants.js'; import * as sync from '../../../core/sync/sync.js'; import { get_mime_lookup, runtime_base, runtime_prefix } from '../../../core/utils.js'; @@ -371,6 +371,7 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) { } const template = load_template(cwd, svelte_config); + const error_page = load_error_page(svelte_config); const rendered = await respond( request, @@ -416,7 +417,7 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) { read: (file) => fs.readFileSync(path.join(svelte_config.kit.files.assets, file)), root, router: svelte_config.kit.browser.router, - template: ({ head, body, assets, nonce }) => { + app_template: ({ head, body, assets, nonce }) => { return ( template .replace(/%sveltekit\.assets%/g, assets) @@ -426,7 +427,12 @@ export async function dev(vite, vite_config, svelte_config, illegal_imports) { .replace('%sveltekit.body%', () => body) ); }, - template_contains_nonce: template.includes('%sveltekit.nonce%'), + app_template_contains_nonce: template.includes('%sveltekit.nonce%'), + error_template: ({ status, message }) => { + return error_page + .replace(/%sveltekit\.status%/g, String(status)) + .replace(/%sveltekit\.message%/g, message); + }, trailing_slash: svelte_config.kit.trailingSlash }, { diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 64c470be62f6..b6c0aef0c58e 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -832,7 +832,7 @@ export function create_client({ target, base, trailing_slash }) { // if we get here, it's because the root `load` function failed, // and we need to fall back to the server - native_navigation(url); + await native_navigation(url); return; } } else { @@ -886,7 +886,7 @@ export function create_client({ target, base, trailing_slash }) { server_data_node = server_data.nodes[0] ?? null; } catch { // at this point we have no choice but to fall back to the server - native_navigation(url); + await native_navigation(url); // @ts-expect-error return; diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 5c03fb045901..ee14ee442f38 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -3,7 +3,7 @@ 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 } from './utils.js'; +import { serialize_error, GENERIC_ERROR, static_error_page } 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'; @@ -360,10 +360,7 @@ export async function respond(request, options, state) { }); } catch (/** @type {unknown} */ e) { const error = coalesce_to_error(e); - - return new Response(options.dev ? error.stack : error.message, { - status: 500 - }); + return static_error_page(options, 500, error.message); } } } diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index d350441a7158..ce9c3e0c49fc 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -2,7 +2,7 @@ import { devalue } from 'devalue'; import { negotiate } from '../../../utils/http.js'; import { render_response } from './render.js'; import { respond_with_error } from './respond_with_error.js'; -import { method_not_allowed, error_to_pojo, allowed_methods } from '../utils.js'; +import { method_not_allowed, error_to_pojo, allowed_methods, static_error_page } from '../utils.js'; import { create_fetch } from './fetch.js'; import { HttpError, Redirect } from '../../control.js'; import { error, json } from '../../../exports/index.js'; @@ -281,12 +281,11 @@ export async function render_page(event, route, page, options, state, resolve_op } // if we're still here, it means the error happened in the root layout, - // which means we have to fall back to a plain text response - // TODO since the requester is expecting HTML, maybe it makes sense to - // doll this up a bit - return new Response( - error instanceof HttpError ? error.message : options.get_stack(error), - { status } + // which means we have to fall back to error.html + return static_error_page( + options, + status, + /** @type {HttpError | Error} */ (error).message ); } } else { diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 9ed1d1e647a1..8a47d0f85456 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -49,7 +49,7 @@ export async function render_response({ throw new Error('Cannot use prerendering if config.kit.csp.mode === "nonce"'); } - if (options.template_contains_nonce) { + if (options.app_template_contains_nonce) { throw new Error('Cannot use prerendering if page template contains %sveltekit.nonce%'); } } @@ -346,7 +346,7 @@ export async function render_response({ // TODO flush chunks as early as we can const html = (await resolve_opts.transformPageChunk({ - html: options.template({ head, body, assets, nonce: /** @type {string} */ (csp.nonce) }), + html: options.app_template({ head, body, assets, nonce: /** @type {string} */ (csp.nonce) }), done: true })) || ''; diff --git a/packages/kit/src/runtime/server/page/respond_with_error.js b/packages/kit/src/runtime/server/page/respond_with_error.js index b0f87a5f5859..50e53ed439c5 100644 --- a/packages/kit/src/runtime/server/page/respond_with_error.js +++ b/packages/kit/src/runtime/server/page/respond_with_error.js @@ -1,7 +1,7 @@ import { render_response } from './render.js'; import { load_data, load_server_data } from './load_data.js'; import { coalesce_to_error } from '../../../utils/error.js'; -import { GENERIC_ERROR } from '../utils.js'; +import { GENERIC_ERROR, static_error_page } from '../utils.js'; import { create_fetch } from './fetch.js'; /** @@ -87,8 +87,6 @@ export async function respond_with_error({ event, options, state, status, error, options.handle_error(error, event); - return new Response(error.stack, { - status: 500 - }); + return static_error_page(options, 500, error.message); } } diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 09b36614ebc9..16baabee3edb 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -135,3 +135,17 @@ export function data_response(data) { }); } } + +/** + * Return as a response that renders the error.html + * + * @param {import('types').SSROptions} options + * @param {number} status + * @param {string} message + */ +export function static_error_page(options, status, message) { + return new Response(options.error_template({ status, message }), { + headers: { 'content-type': 'text/html; charset=utf-8' }, + status + }); +} diff --git a/packages/kit/test/apps/basics/src/error.html b/packages/kit/test/apps/basics/src/error.html new file mode 100644 index 000000000000..14d6424789f9 --- /dev/null +++ b/packages/kit/test/apps/basics/src/error.html @@ -0,0 +1,11 @@ + + + + + Error + + +

Error - %sveltekit.status%

+

This is the static error page with the following message: %sveltekit.message%

+ + diff --git a/packages/kit/test/apps/basics/src/routes/+layout.server.js b/packages/kit/test/apps/basics/src/routes/+layout.server.js index bacb24019ecc..cb10abf04b64 100644 --- a/packages/kit/test/apps/basics/src/routes/+layout.server.js +++ b/packages/kit/test/apps/basics/src/routes/+layout.server.js @@ -1,4 +1,16 @@ +let should_fail = false; +/** + * @param {boolean} value + */ +export function set_should_fail(value) { + should_fail = value; +} + export async function load() { + if (should_fail) { + set_should_fail(false); + throw new Error('Failed to load'); + } // Do NOT make this load function depend on something which would cause it to rerun return { rootlayout: 'rootlayout' diff --git a/packages/kit/test/apps/basics/src/routes/errors/error-html/+page.svelte b/packages/kit/test/apps/basics/src/routes/errors/error-html/+page.svelte new file mode 100644 index 000000000000..2fda2e2e0f14 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/errors/error-html/+page.svelte @@ -0,0 +1,8 @@ + + + diff --git a/packages/kit/test/apps/basics/src/routes/errors/error-html/make-root-fail/+server.js b/packages/kit/test/apps/basics/src/routes/errors/error-html/make-root-fail/+server.js new file mode 100644 index 000000000000..7c3f14cfd285 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/errors/error-html/make-root-fail/+server.js @@ -0,0 +1,6 @@ +import { set_should_fail } from '../../../+layout.server'; + +export function GET() { + set_should_fail(true); + return new Response(); +} diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 03da4032df27..fd73413f6823 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -305,6 +305,15 @@ test.describe('Errors', () => { ); expect(await page.innerHTML('h1')).toBe('401'); }); + + test('Root error falls back to error.html', async ({ page }) => { + await page.goto('/errors/error-html'); + await page.click('button'); + expect(await page.textContent('h1')).toBe('Error - 500'); + expect(await page.textContent('p')).toBe( + 'This is the static error page with the following message: Failed to load' + ); + }); }); test.describe('Load', () => { diff --git a/packages/kit/test/apps/options/svelte.config.js b/packages/kit/test/apps/options/svelte.config.js index accce4d94146..5bef3229bcaf 100644 --- a/packages/kit/test/apps/options/svelte.config.js +++ b/packages/kit/test/apps/options/svelte.config.js @@ -11,7 +11,7 @@ const config = { assets: 'public', lib: 'source/components', routes: 'source/pages', - template: 'source/template.html', + appTemplate: 'source/template.html', // while we specify a path for the service worker, we expect it to not exist in the test serviceWorker: 'source/service-worker' }, diff --git a/packages/kit/types/index.d.ts b/packages/kit/types/index.d.ts index f5033d642bea..780013c63ed4 100644 --- a/packages/kit/types/index.d.ts +++ b/packages/kit/types/index.d.ts @@ -142,7 +142,8 @@ export interface KitConfig { params?: string; routes?: string; serviceWorker?: string; - template?: string; + appTemplate?: string; + errorTemplate?: string; }; inlineStyleThreshold?: number; methodOverride?: { diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 2543bc14c35f..21f21f08f6ea 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -309,7 +309,7 @@ export interface SSROptions { root: SSRComponent['default']; router: boolean; service_worker?: string; - template({ + app_template({ head, body, assets, @@ -320,7 +320,8 @@ export interface SSROptions { assets: string; nonce: string; }): string; - template_contains_nonce: boolean; + app_template_contains_nonce: boolean; + error_template({ message, status }: { message: string; status: number }): string; trailing_slash: TrailingSlash; }