diff --git a/documentation/docs/05-load.md b/documentation/docs/05-load.md index 8671ac310d64..7bcf13c70e20 100644 --- a/documentation/docs/05-load.md +++ b/documentation/docs/05-load.md @@ -143,7 +143,7 @@ export async function load({ depends }) { - it can be used to make credentialed requests on the server, as it inherits the `cookie` and `authorization` headers for the page request - it can make relative requests on the server (ordinarily, `fetch` requires a URL with an origin when used in a server context) - internal requests (e.g. for `+server.js` routes) go direct to the handler function when running on the server, without the overhead of an HTTP call -- during server-side rendering, the response will be captured and inlined into the rendered HTML. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](/docs/hooks#handle) +- during server-side rendering, the response will be captured and inlined into the rendered HTML. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](/docs/hooks#hooks-server-js-handle) - during hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request > Cookies will only be passed through if the target host is the same as the SvelteKit application or a more specific subdomain of it. @@ -308,7 +308,7 @@ export function load({ locals }) { } ``` -If an _unexpected_ error is thrown, SvelteKit will invoke [`handleError`](/docs/hooks#handleerror) and treat it as a 500 Internal Server Error. +If an _unexpected_ error is thrown, SvelteKit will invoke [`handleError`](/docs/hooks#hooks-server-js-handleerror) and treat it as a 500 Internal Error. > In development, stack traces for unexpected errors are visible as `$page.error.stack`. In production, stack traces are hidden. diff --git a/documentation/docs/07-hooks.md b/documentation/docs/07-hooks.md index 0a5effb21abc..827d9e7a7ff1 100644 --- a/documentation/docs/07-hooks.md +++ b/documentation/docs/07-hooks.md @@ -2,11 +2,20 @@ title: Hooks --- -An optional `src/hooks.js` (or `src/hooks.ts`, or `src/hooks/index.js`) file exports three functions, all optional, that run on the server — `handle`, `handleError` and `handleFetch`. +'Hooks' are app-wide functions you declare that SvelteKit will call in response to specific events, giving you fine-grained control over the framework's behaviour. -> The location of this file can be [configured](/docs/configuration) as `config.kit.files.hooks` +There are two hooks files, both optional: -### handle +- `src/hooks.server.js` — your app's server hooks +- `src/hooks.client.js` — your app's client hooks + +> You can configure the location of these files with [`config.kit.files.hooks`](/docs/configuration#files). + +### Server hooks + +The following hooks can be added to `src/hooks.server.js`: + +#### handle This function runs every time the SvelteKit server receives a [request](/docs/web-standards#fetch-apis-request) — whether that happens while the app is running, or during [prerendering](/docs/page-options#prerender) — and determines the [response](/docs/web-standards#fetch-apis-response). It receives an `event` object representing the request and a function called `resolve`, which renders the route and generates a `Response`. This allows you to modify response headers or bodies, or bypass SvelteKit entirely (for implementing routes programmatically, for example). @@ -79,47 +88,9 @@ export async function handle({ event, resolve }) { } ``` -### handleError - -If an error is thrown during loading or rendering, this function will be called with the `error` and the `event` that caused it. This allows you to send data to an error tracking service, or to customise the formatting before printing the error to the console. - -During development, if an error occurs because of a syntax error in your Svelte code, a `frame` property will be appended highlighting the location of the error. - -If unimplemented, SvelteKit will log the error with default formatting. +#### handleFetch -```js -/// file: src/hooks.js -// @filename: ambient.d.ts -const Sentry: any; - -// @filename: index.js -// ---cut--- -/** @type {import('@sveltejs/kit').HandleError} */ -export function handleError({ error, event }) { - // example integration with https://sentry.io/ - Sentry.captureException(error, { event }); -} -``` - -> `handleError` is only called for _unexpected_ errors. It is not called for errors created with the [`error`](/docs/modules#sveltejs-kit-error) function imported from `@sveltejs/kit`, as these are _expected_ errors. - -### handleFetch - -This function allows you to modify (or replace) a `fetch` request that happens inside a `load` function that runs on the server (or during pre-rendering). - -For example, you might need to include custom headers that are added by a proxy that sits in front of your app: - -```js -// @errors: 2345 -/** @type {import('@sveltejs/kit').HandleFetch} */ -export async function handleFetch({ event, request, fetch }) { - const name = 'x-geolocation-city'; - const value = event.request.headers.get(name); - request.headers.set(name, value); - - return fetch(request); -} -``` +This function allows you to modify (or replace) a `fetch` request for an external resource that happens inside a `load` function that runs on the server (or during pre-rendering). Or your `load` function might make a request to a public URL like `https://api.yourapp.com` when the user performs a client-side navigation to the respective page, but during SSR it might make sense to hit the API directly (bypassing whatever proxies and load balancers sit between it and the public internet). @@ -138,7 +109,7 @@ export async function handleFetch({ request, fetch }) { } ``` -#### Credentials +**Credentials** For same-origin requests, SvelteKit's `fetch` implementation will forward `cookie` and `authorization` headers unless the `credentials` option is set to `"omit"`. @@ -157,3 +128,40 @@ export async function handleFetch({ event, request, fetch }) { return fetch(request); } ``` + +### Shared hooks + +The following can be added to `src/hooks.server.js` _and_ `src/hooks.client.js`: + +#### handleError + +If an unexpected error is thrown during loading or rendering, this function will be called with the `error` and the `event`. This allows for two things: + +- you can log the error +- you can generate a custom representation of the error that is safe to show to users, omitting sensitive details like messages and stack traces. The returned value, which defaults to `{ message: 'Internal Error' }`, becomes the value of `$page.error`. To make this type-safe, you can customize the expected shape by declaring an `App.PageError` interface (which must include `message: string`, to guarantee sensible fallback behavior). + +```js +/// file: src/hooks.server.js +// @errors: 2322 2571 +// @filename: ambient.d.ts +const Sentry: any; + +// @filename: index.js +// ---cut--- +/** @type {import('@sveltejs/kit').HandleServerError} */ +export function handleError({ error, event }) { + // example integration with https://sentry.io/ + Sentry.captureException(error, { event }); + + return { + message: 'Whoops!', + code: error.code ?? 'UNKNOWN' + }; +} +``` + +> In `src/hooks.client.js`, the type of `handleError` is `HandleClientError` instead of `HandleServerError`, and `event` is a `NavigationEvent` rather than a `RequestEvent`. + +This function is not called for _expected_ errors (those thrown with the [`error`](/docs/modules#sveltejs-kit-error) function imported from `@sveltejs/kit`). + +During development, if an error occurs because of a syntax error in your Svelte code, a `frame` property will be appended highlighting the location of the error. \ No newline at end of file diff --git a/documentation/docs/15-configuration.md b/documentation/docs/15-configuration.md index 535aedb03cf2..ad5dd2bd661a 100644 --- a/documentation/docs/15-configuration.md +++ b/documentation/docs/15-configuration.md @@ -34,7 +34,10 @@ const config = { }, files: { assets: 'static', - hooks: 'src/hooks', + hooks: { + client: 'src/hooks.client', + server: 'src/hooks.server' + }, lib: 'src/lib', params: 'src/params', routes: 'src/routes', @@ -179,7 +182,7 @@ Environment variable configuration: An object containing zero or more of the following `string` values: - `assets` — a place to put static files that should have stable URLs and undergo no processing, such as `favicon.ico` or `manifest.json` -- `hooks` — the location of your hooks module (see [Hooks](/docs/hooks)) +- `hooks` — the location of your client and server hooks (see [Hooks](/docs/hooks)) - `lib` — your app's internal library, accessible throughout the codebase as `$lib` - `params` — a directory containing [parameter matchers](/docs/routing#advanced-routing-matching) - `routes` — the files that define the structure of your app (see [Routing](/docs/routing)) @@ -296,7 +299,7 @@ Whether to remove, append, or ignore trailing slashes when resolving URLs (note This option also affects [prerendering](/docs/page-options#prerender). If `trailingSlash` is `always`, a route like `/about` will result in an `about/index.html` file, otherwise it will create `about.html`, mirroring static webserver conventions. -> Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. If you use this option, ensure that you implement logic for conditionally adding or removing trailing slashes from `request.path` inside your [`handle`](/docs/hooks#handle) function. +> Ignoring trailing slashes is not recommended — the semantics of relative paths differ between the two cases (`./y` from `/x` is `/y`, but from `/x/` is `/x/y`), and `/x` and `/x/` are treated as separate URLs which is harmful to SEO. If you use this option, ensure that you implement logic for conditionally adding or removing trailing slashes from `request.path` inside your [`handle`](/docs/hooks#hooks-server-js-handle) function. ### version diff --git a/documentation/docs/17-seo.md b/documentation/docs/17-seo.md index 6642cf509f53..0a6bb5cf19c3 100644 --- a/documentation/docs/17-seo.md +++ b/documentation/docs/17-seo.md @@ -8,7 +8,7 @@ The most important aspect of SEO is to create high-quality content that is widel #### SSR -While search engines have got better in recent years at indexing content that was rendered with client-side JavaScript, server-side rendered content is indexed more frequently and reliably. SvelteKit employs SSR by default, and while you can disable it in [`handle`](/docs/hooks#handle), you should leave it on unless you have a good reason not to. +While search engines have got better in recent years at indexing content that was rendered with client-side JavaScript, server-side rendered content is indexed more frequently and reliably. SvelteKit employs SSR by default, and while you can disable it in [`handle`](/docs/hooks#hooks-server-js-handle), you should leave it on unless you have a good reason not to. > SvelteKit's rendering is highly configurable and you can implement [dynamic rendering](https://developers.google.com/search/docs/advanced/javascript/dynamic-rendering) if necessary. It's not generally recommended, since SSR has other benefits beyond SEO. diff --git a/documentation/docs/19-accessibility.md b/documentation/docs/19-accessibility.md index b2ca4c9aa789..b015eb7e5c0b 100644 --- a/documentation/docs/19-accessibility.md +++ b/documentation/docs/19-accessibility.md @@ -54,7 +54,7 @@ By default, SvelteKit's page template sets the default language of the document ``` -If your content is available in multiple languages, you should set the `lang` attribute based on the language of the current page. You can do this with SvelteKit's [handle hook](/docs/hooks#handle): +If your content is available in multiple languages, you should set the `lang` attribute based on the language of the current page. You can do this with SvelteKit's [handle hook](/docs/hooks#hooks-server-js-handle): ```html /// file: src/app.html diff --git a/documentation/docs/80-migrating.md b/documentation/docs/80-migrating.md index bc344940878b..f3b17d1e370b 100644 --- a/documentation/docs/80-migrating.md +++ b/documentation/docs/80-migrating.md @@ -150,7 +150,7 @@ See [the FAQ](/faq#integrations) for detailed information about integrations. #### HTML minifier -Sapper includes `html-minifier` by default. SvelteKit does not include this, but it can be added as a [hook](/docs/hooks#handle): +Sapper includes `html-minifier` by default. SvelteKit does not include this, but it can be added as a [hook](/docs/hooks#hooks-server-js-handle): ```js // @filename: ambient.d.ts diff --git a/packages/adapter-cloudflare/README.md b/packages/adapter-cloudflare/README.md index 55d73668f628..ac335d69b8c6 100644 --- a/packages/adapter-cloudflare/README.md +++ b/packages/adapter-cloudflare/README.md @@ -85,7 +85,7 @@ Functions contained in the `/functions` directory at the project's root will _no The [`_headers` and `_redirects`](config files) files specific to Cloudflare Pages can be used for static asset responses (like images) by putting them into the `/static` folder. -However, they will have no effect on responses dynamically rendered by SvelteKit, which should return custom headers or redirect responses from [endpoints](https://kit.svelte.dev/docs/routing#endpoints) or with the [`handle`](https://kit.svelte.dev/docs/hooks#handle) hook. +However, they will have no effect on responses dynamically rendered by SvelteKit, which should return custom headers or redirect responses from [endpoints](https://kit.svelte.dev/docs/routing#endpoints) or with the [`handle`](https://kit.svelte.dev/docs/hooks#hooks-server-js-handle) hook. ## Changelog diff --git a/packages/create-svelte/templates/default/src/app.d.ts b/packages/create-svelte/templates/default/src/app.d.ts index d7506f89a087..89fa05e74685 100644 --- a/packages/create-svelte/templates/default/src/app.d.ts +++ b/packages/create-svelte/templates/default/src/app.d.ts @@ -8,5 +8,7 @@ declare namespace App { // interface PageData {} + // interface PageError {} + // interface Platform {} } diff --git a/packages/create-svelte/templates/default/src/hooks.ts b/packages/create-svelte/templates/default/src/hooks.server.ts similarity index 100% rename from packages/create-svelte/templates/default/src/hooks.ts rename to packages/create-svelte/templates/default/src/hooks.server.ts diff --git a/packages/create-svelte/templates/skeleton/src/app.d.ts b/packages/create-svelte/templates/skeleton/src/app.d.ts index 14ac5d1b5d40..6f53deca8a8a 100644 --- a/packages/create-svelte/templates/skeleton/src/app.d.ts +++ b/packages/create-svelte/templates/skeleton/src/app.d.ts @@ -4,5 +4,6 @@ declare namespace App { // interface Locals {} // interface PageData {} + // interface PageError {} // interface Platform {} } diff --git a/packages/create-svelte/templates/skeletonlib/src/app.d.ts b/packages/create-svelte/templates/skeletonlib/src/app.d.ts index 3a4446871f35..2c2f9ec4f055 100644 --- a/packages/create-svelte/templates/skeletonlib/src/app.d.ts +++ b/packages/create-svelte/templates/skeletonlib/src/app.d.ts @@ -6,5 +6,6 @@ declare namespace App { // interface Locals {} // interface PageData {} + // interface PageError {} // interface Platform {} } diff --git a/packages/kit/src/core/config/index.js b/packages/kit/src/core/config/index.js index 3f5d53dc4f11..39f4160cdac0 100644 --- a/packages/kit/src/core/config/index.js +++ b/packages/kit/src/core/config/index.js @@ -77,8 +77,13 @@ function process_config(config, { cwd = process.cwd() } = {}) { // 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 (key === 'hooks') { + validated.kit.files.hooks.client = path.resolve(cwd, validated.kit.files.hooks.client); + validated.kit.files.hooks.server = path.resolve(cwd, validated.kit.files.hooks.server); + } else { + // @ts-expect-error + validated.kit.files[key] = path.resolve(cwd, validated.kit.files[key]); + } } if (!fs.existsSync(validated.kit.files.errorTemplate)) { diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 4ca04f1d1233..4285b614e5f5 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -82,7 +82,10 @@ const get_defaults = (prefix = '') => ({ }, files: { assets: join(prefix, 'static'), - hooks: join(prefix, 'src/hooks'), + hooks: { + client: join(prefix, 'src/hooks.client'), + server: join(prefix, 'src/hooks.server') + }, lib: join(prefix, 'src/lib'), params: join(prefix, 'src/params'), routes: join(prefix, 'src/routes'), diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index e5e84ec4ce81..dbffb030d0b5 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -141,7 +141,19 @@ const options = object( files: object({ assets: string('static'), - hooks: string(join('src', 'hooks')), + hooks: (input, keypath) => { + // TODO remove this for the 1.0 release + if (typeof input === 'string') { + throw new Error( + `${keypath} is an object with { server: string, client: string } now. See the PR for more information: https://github.com/sveltejs/kit/pull/6586` + ); + } + + return object({ + client: string(join('src', 'hooks.client')), + server: string(join('src', 'hooks.server')) + })(input, keypath); + }, lib: string(join('src', 'lib')), params: string(join('src', 'params')), routes: string(join('src', 'routes')), diff --git a/packages/kit/src/core/sync/sync.js b/packages/kit/src/core/sync/sync.js index 153923d7a3b6..5949de8fa2d0 100644 --- a/packages/kit/src/core/sync/sync.js +++ b/packages/kit/src/core/sync/sync.js @@ -26,7 +26,7 @@ export async function create(config) { const output = path.join(config.kit.outDir, 'generated'); - write_client_manifest(manifest_data, output); + write_client_manifest(config, manifest_data, output); write_root(manifest_data, output); write_matchers(manifest_data, output); await write_all_types(config, manifest_data); diff --git a/packages/kit/src/core/sync/write_client_manifest.js b/packages/kit/src/core/sync/write_client_manifest.js index 2fc49b937ce0..89aa584a6fda 100644 --- a/packages/kit/src/core/sync/write_client_manifest.js +++ b/packages/kit/src/core/sync/write_client_manifest.js @@ -1,14 +1,16 @@ import { relative } from 'path'; +import { posixify, resolve_entry } from '../../utils/filesystem.js'; import { s } from '../../utils/misc.js'; import { trim, write_if_changed } from './utils.js'; /** * Writes the client manifest to disk. The manifest is used to power the router. It contains the * list of routes and corresponding Svelte components (i.e. pages and layouts). + * @param {import('types').ValidatedConfig} config * @param {import('types').ManifestData} manifest_data * @param {string} output */ -export function write_client_manifest(manifest_data, output) { +export function write_client_manifest(config, manifest_data, output) { /** * Creates a module that exports a `CSRPageNode` * @param {import('types').PageNode} node @@ -78,10 +80,14 @@ export function write_client_manifest(manifest_data, output) { .join(',\n\t\t')} }`.replace(/^\t/gm, ''); + const hooks_file = resolve_entry(config.kit.files.hooks.client); + // String representation of __GENERATED__/client-manifest.js write_if_changed( `${output}/client-manifest.js`, trim(` + ${hooks_file ? `import * as client_hooks from '${posixify(relative(output, hooks_file))}';` : ''} + export { matchers } from './client-matchers.js'; export const nodes = [${nodes}]; @@ -89,6 +95,12 @@ export function write_client_manifest(manifest_data, output) { export const server_loads = [${[...layouts_with_server_load].join(',')}]; export const dictionary = ${dictionary}; + + export const hooks = { + handleError: ${ + hooks_file ? 'client_hooks.handleError || ' : '' + }(({ error }) => { console.error(error); return { message: 'Internal Error' }; }), + }; `) ); } diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index ab5344732c22..1f9ed9b2e3df 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -1,22 +1,17 @@ import { HttpError, Redirect, ValidationError } from '../runtime/control.js'; +// For some reason we need to type the params as well here, +// JSdoc doesn't seem to like @type with function overloads /** - * Creates an `HttpError` object with an HTTP status code and an optional message. - * This object, if thrown during request handling, will cause SvelteKit to - * return an error response without invoking `handleError` + * @type {import('@sveltejs/kit').error} * @param {number} status - * @param {string | undefined} [message] + * @param {any} message */ export function error(status, message) { return new HttpError(status, message); } -/** - * Creates a `Redirect` object. If thrown during request handling, SvelteKit will - * return a redirect response. - * @param {number} status - * @param {string} location - */ +/** @type {import('@sveltejs/kit').redirect} */ export function redirect(status, location) { if (isNaN(status) || status < 300 || status > 399) { throw new Error('Invalid status code'); @@ -25,11 +20,7 @@ export function redirect(status, location) { return new Redirect(status, location); } -/** - * Generates a JSON `Response` object from the supplied data. - * @param {any} data - * @param {ResponseInit} [init] - */ +/** @type {import('@sveltejs/kit').json} */ export function json(data, init) { // TODO deprecate this in favour of `Response.json` when it's // more widely supported diff --git a/packages/kit/src/exports/vite/build/build_server.js b/packages/kit/src/exports/vite/build/build_server.js index e8a9d8eb19d9..11e1390fba52 100644 --- a/packages/kit/src/exports/vite/build/build_server.js +++ b/packages/kit/src/exports/vite/build/build_server.js @@ -1,7 +1,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 { mkdirp, posixify, resolve_entry } from '../../../utils/filesystem.js'; +import { get_vite_config, merge_vite_configs } from '../utils.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'; @@ -58,9 +58,8 @@ export class Server { check_origin: ${s(config.kit.csrf.checkOrigin)}, }, dev: false, - get_stack: error => String(error), // for security handle_error: (error, event) => { - this.options.hooks.handleError({ + return this.options.hooks.handleError({ error, event, @@ -69,8 +68,7 @@ export class Server { get request() { throw new Error('request in handleError has been replaced with event. See https://github.com/sveltejs/kit/pull/3384 for details'); } - }); - error.stack = this.options.get_stack(error); + }) ?? { message: 'Internal Error' }; }, hooks: null, manifest, @@ -158,7 +156,24 @@ export async function build_server(options, client) { service_worker_entry_file } = options; - let hooks_file = resolve_entry(config.kit.files.hooks); + let hooks_file = resolve_entry(config.kit.files.hooks.server); + + // TODO remove for 1.0 + if (!hooks_file) { + const old_file = resolve_entry(path.join(process.cwd(), 'src', 'hooks')); + if (old_file && fs.existsSync(old_file)) { + throw new Error( + `Rename your server hook file from ${posixify( + path.relative(process.cwd(), old_file) + )} to ${posixify( + path.relative(process.cwd(), config.kit.files.hooks.server) + )}.${path.extname( + old_file + )} (because there's also client hooks now). See the PR for more information: https://github.com/sveltejs/kit/pull/6586` + ); + } + } + if (!hooks_file || !fs.existsSync(hooks_file)) { hooks_file = path.join(config.kit.outDir, 'build/hooks.js'); fs.writeFileSync(hooks_file, ''); diff --git a/packages/kit/src/exports/vite/dev/index.js b/packages/kit/src/exports/vite/dev/index.js index c78c024034a6..2896969afa4c 100644 --- a/packages/kit/src/exports/vite/dev/index.js +++ b/packages/kit/src/exports/vite/dev/index.js @@ -6,12 +6,12 @@ import { URL } from 'url'; 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 { posixify, resolve_entry } from '../../../utils/filesystem.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'; -import { prevent_illegal_vite_imports, resolve_entry } from '../utils.js'; +import { prevent_illegal_vite_imports } from '../utils.js'; import { compact } from '../../../utils/array.js'; import { normalizePath } from 'vite'; @@ -311,11 +311,28 @@ export async function dev(vite, vite_config, svelte_config) { ); } - /** @type {Partial} */ - const user_hooks = resolve_entry(svelte_config.kit.files.hooks) - ? await vite.ssrLoadModule(`/${svelte_config.kit.files.hooks}`) + const hooks_file = svelte_config.kit.files.hooks.server; + /** @type {Partial} */ + const user_hooks = resolve_entry(hooks_file) + ? await vite.ssrLoadModule(`/${hooks_file}`) : {}; + // TODO remove for 1.0 + if (!resolve_entry(hooks_file)) { + const old_file = resolve_entry(path.join(process.cwd(), 'src', 'hooks')); + if (old_file && fs.existsSync(old_file)) { + throw new Error( + `Rename your server hook file from ${posixify( + path.relative(process.cwd(), old_file) + )} to ${posixify( + path.relative(process.cwd(), svelte_config.kit.files.hooks.server) + )}.${path.extname( + old_file + )} (because there's also client hooks now). See the PR for more information: https://github.com/sveltejs/kit/pull/6586` + ); + } + } + const handle = user_hooks.handle || (({ event, resolve }) => resolve(event)); // TODO remove for 1.0 @@ -326,12 +343,13 @@ export async function dev(vite, vite_config, svelte_config) { ); } - /** @type {import('types').Hooks} */ + /** @type {import('types').ServerHooks} */ const hooks = { handle, handleError: user_hooks.handleError || - (({ /** @type {Error & { frame?: string }} */ error }) => { + (({ error: e }) => { + const error = /** @type {Error & { frame?: string }} */ (e); console.error(colors.bold().red(error.message)); if (error.frame) { console.error(colors.gray(error.frame)); @@ -390,28 +408,29 @@ export async function dev(vite, vite_config, svelte_config) { check_origin: svelte_config.kit.csrf.checkOrigin }, dev: true, - get_stack: (error) => fix_stack_trace(error), handle_error: (error, event) => { - hooks.handleError({ - error: new Proxy(error, { - get: (target, property) => { - if (property === 'stack') { - return fix_stack_trace(error); + return ( + hooks.handleError({ + error: new Proxy(error, { + get: (target, property) => { + if (property === 'stack') { + return fix_stack_trace(error); + } + + return Reflect.get(target, property, target); } - - return Reflect.get(target, property, target); + }), + event, + + // TODO remove for 1.0 + // @ts-expect-error + get request() { + throw new Error( + 'request in handleError has been replaced with event. See https://github.com/sveltejs/kit/pull/3384 for details' + ); } - }), - event, - - // TODO remove for 1.0 - // @ts-expect-error - get request() { - throw new Error( - 'request in handleError has been replaced with event. See https://github.com/sveltejs/kit/pull/3384 for details' - ); - } - }); + }) ?? { message: 'Internal Error' } + ); }, hooks, manifest, diff --git a/packages/kit/src/exports/vite/index.js b/packages/kit/src/exports/vite/index.js index 87bff27433a5..b7eda46b4402 100644 --- a/packages/kit/src/exports/vite/index.js +++ b/packages/kit/src/exports/vite/index.js @@ -4,7 +4,7 @@ import path from 'node:path'; import colors from 'kleur'; import { svelte } from '@sveltejs/vite-plugin-svelte'; import * as vite from 'vite'; -import { mkdirp, posixify, rimraf } from '../../utils/filesystem.js'; +import { mkdirp, posixify, resolve_entry, rimraf } from '../../utils/filesystem.js'; import * as sync from '../../core/sync/sync.js'; import { build_server } from './build/build_server.js'; import { build_service_worker } from './build/build_service_worker.js'; @@ -14,7 +14,7 @@ import { generate_manifest } from '../../core/generate_manifest/index.js'; import { runtime_directory, logger } from '../../core/utils.js'; import { find_deps, get_default_build_config } from './build/utils.js'; import { preview } from './preview/index.js'; -import { get_aliases, resolve_entry, prevent_illegal_rollup_imports, get_env } from './utils.js'; +import { get_aliases, prevent_illegal_rollup_imports, get_env } from './utils.js'; import { fileURLToPath } from 'node:url'; import { create_static_module, create_dynamic_module } from '../../core/env.js'; @@ -220,6 +220,21 @@ function kit() { return new_config; } + const allow = new Set([ + svelte_config.kit.files.lib, + svelte_config.kit.files.routes, + svelte_config.kit.outDir, + path.resolve(cwd, 'src'), // TODO this isn't correct if user changed all his files to sth else than src (like in test/options) + path.resolve(cwd, 'node_modules'), + path.resolve(vite.searchForWorkspaceRoot(cwd), 'node_modules') + ]); + // We can only add directories to the allow list, so we find out + // if there's a client hooks file and pass its directory + const client_hooks = resolve_entry(svelte_config.kit.files.hooks.client); + if (client_hooks) { + allow.add(path.dirname(client_hooks)); + } + // dev and preview config can be shared /** @type {import('vite').UserConfig} */ const result = { @@ -243,16 +258,7 @@ function kit() { root: cwd, server: { fs: { - allow: [ - ...new Set([ - svelte_config.kit.files.lib, - svelte_config.kit.files.routes, - svelte_config.kit.outDir, - path.resolve(cwd, 'src'), - path.resolve(cwd, 'node_modules'), - path.resolve(vite.searchForWorkspaceRoot(cwd), 'node_modules') - ]) - ] + allow: [...allow] }, watch: { ignored: [ diff --git a/packages/kit/src/exports/vite/utils.js b/packages/kit/src/exports/vite/utils.js index 7559915e4706..fc4ff215935b 100644 --- a/packages/kit/src/exports/vite/utils.js +++ b/packages/kit/src/exports/vite/utils.js @@ -1,4 +1,3 @@ -import fs from 'fs'; import path from 'path'; import { loadConfigFromFile, loadEnv, normalizePath } from 'vite'; import { runtime_directory } from '../../core/utils.js'; @@ -367,35 +366,6 @@ function escape_for_regexp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, (match) => '\\' + match); } -/** - * Given an entry point like [cwd]/src/hooks, returns a filename like [cwd]/src/hooks.js or [cwd]/src/hooks/index.js - * @param {string} entry - * @returns {string|null} - */ -export function resolve_entry(entry) { - if (fs.existsSync(entry)) { - const stats = fs.statSync(entry); - if (stats.isDirectory()) { - return resolve_entry(path.join(entry, 'index')); - } - - return entry; - } else { - const dir = path.dirname(entry); - - if (fs.existsSync(dir)) { - const base = path.basename(entry); - const files = fs.readdirSync(dir); - - const found = files.find((file) => file.replace(/\.[^.]+$/, '') === base); - - if (found) return path.join(dir, found); - } - } - - return null; -} - /** * Load environment variables from process.env and .env files * @param {import('types').ValidatedKitConfig['env']} env_config diff --git a/packages/kit/src/runtime/client/ambient.d.ts b/packages/kit/src/runtime/client/ambient.d.ts index 4cf3c8f9aa40..b9240974dcd5 100644 --- a/packages/kit/src/runtime/client/ambient.d.ts +++ b/packages/kit/src/runtime/client/ambient.d.ts @@ -1,5 +1,5 @@ declare module '__GENERATED__/client-manifest.js' { - import { CSRPageNodeLoader, ParamMatcher } from 'types'; + import { CSRPageNodeLoader, ClientHooks, ParamMatcher } from 'types'; /** * A list of all the error/layout/page nodes used in the app @@ -21,4 +21,6 @@ declare module '__GENERATED__/client-manifest.js' { export const dictionary: Record; export const matchers: Record; + + export const hooks: ClientHooks; } diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 2659942c9614..755f0d85c428 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -4,10 +4,9 @@ import { make_trackable, decode_params, normalize_path } from '../../utils/url.j import { find_anchor, get_base_uri, scroll_state } from './utils.js'; import { lock_fetch, unlock_fetch, initial_fetch, subsequent_fetch } from './fetcher.js'; import { parse } from './parse.js'; -import { error } from '../../exports/index.js'; import Root from '__GENERATED__/root.svelte'; -import { nodes, server_loads, dictionary, matchers } from '__GENERATED__/client-manifest.js'; +import { nodes, server_loads, dictionary, matchers, hooks } from '__GENERATED__/client-manifest.js'; import { HttpError, Redirect } from '../control.js'; import { stores } from './singletons.js'; import { DATA_SUFFIX } from '../../constants.js'; @@ -386,7 +385,7 @@ export function create_client({ target, base, trailing_slash }) { * params: Record; * branch: Array; * status: number; - * error: HttpError | Error | null; + * error: App.PageError | null; * route: import('types').CSRRoute | null; * form?: Record | null; * }} opts @@ -756,12 +755,8 @@ export function create_client({ target, base, trailing_slash }) { parent_changed = true; if (server_data_node?.type === 'error') { - if (server_data_node.httperror) { - // reconstruct as an HttpError - throw error(server_data_node.httperror.status, server_data_node.httperror.message); - } else { - throw server_data_node.error; - } + // rethrow and catch below + throw server_data_node; } return load_node({ @@ -790,17 +785,29 @@ export function create_client({ target, base, trailing_slash }) { if (loaders[i]) { try { branch.push(await branch_promises[i]); - } catch (e) { - const error = normalize_error(e); - - if (error instanceof Redirect) { + } catch (err) { + if (err instanceof Redirect) { return { type: 'redirect', - location: error.location + location: err.location }; } - const status = e instanceof HttpError ? e.status : 500; + let status = 500; + /** @type {App.PageError} */ + let error; + + if (server_data_nodes?.includes(/** @type {import('types').ServerErrorNode} */ (err))) { + // this is the server error rethrown above, reconstruct but don't invoke + // the client error handler; it should've already been handled on the server + status = /** @type {import('types').ServerErrorNode} */ (err).status ?? status; + error = /** @type {import('types').ServerErrorNode} */ (err).error; + } else if (err instanceof HttpError) { + status = err.status; + error = err.body; + } else { + error = handle_error(err, { params, url, routeId: route.id }); + } while (i--) { if (errors[i]) { @@ -920,7 +927,10 @@ export function create_client({ target, base, trailing_slash }) { params, branch: [root_layout, root_error], status, - error, + error: + error instanceof HttpError + ? error.body + : handle_error(error, { url, params, routeId: null }), route: null }); } @@ -1395,7 +1405,7 @@ export function create_client({ target, base, trailing_slash }) { _hydrate: async ({ status, - error: original_error, // TODO get rid of this + error, node_ids, params, routeId, @@ -1432,15 +1442,7 @@ export function create_client({ target, base, trailing_slash }) { params, branch: await Promise.all(branch_promises), status, - error: /** @type {import('../server/page/types').SerializedHttpError} */ (original_error) - ?.__is_http_error - ? new HttpError( - /** @type {import('../server/page/types').SerializedHttpError} */ ( - original_error - ).status, - original_error.message - ) - : original_error, + error, form, route: routes.find((route) => route.id === routeId) ?? null }); @@ -1498,6 +1500,15 @@ async function load_data(url, invalid) { return server_data; } +/** + * @param {unknown} error + * @param {import('types').NavigationEvent} event + * @returns {App.PageError} + */ +function handle_error(error, event) { + return hooks.handleError({ error, event }) ?? /** @type {any} */ ({ error: 'Internal Error' }); +} + // TODO remove for 1.0 const properties = [ 'hash', diff --git a/packages/kit/src/runtime/client/types.d.ts b/packages/kit/src/runtime/client/types.d.ts index 27818e66a58b..a566a4ba2d0a 100644 --- a/packages/kit/src/runtime/client/types.d.ts +++ b/packages/kit/src/runtime/client/types.d.ts @@ -9,8 +9,6 @@ import { prefetchRoutes } from '$app/navigation'; import { CSRPageNode, CSRPageNodeLoader, CSRRoute, Uses } from 'types'; -import { HttpError } from '../control.js'; -import { SerializedHttpError } from '../server/page/types.js'; export interface Client { // public API, exposed via $app/navigation @@ -27,7 +25,7 @@ export interface Client { // private API _hydrate: (opts: { status: number; - error: Error | SerializedHttpError; + error: App.PageError; node_ids: number[]; params: Record; routeId: string | null; @@ -79,7 +77,7 @@ export interface DataNode { export interface NavigationState { branch: Array; - error: HttpError | Error | null; + error: App.PageError | null; params: Record; route: CSRRoute | null; session_id: number; diff --git a/packages/kit/src/runtime/control.js b/packages/kit/src/runtime/control.js index 680ed5d5cb82..ba2e9db90eae 100644 --- a/packages/kit/src/runtime/control.js +++ b/packages/kit/src/runtime/control.js @@ -1,23 +1,21 @@ export class HttpError { - // without these, things like `$page.error.stack` will error. we don't want to - // include a stack for these sorts of errors, but we also don't want red - // squigglies everywhere, so this feels like a not-terribile compromise - name = 'HttpError'; - - /** @type {void} */ - stack = undefined; - /** * @param {number} status - * @param {string | undefined} message + * @param {{message: string} extends App.PageError ? (App.PageError | string | undefined) : App.PageError} body */ - constructor(status, message) { + constructor(status, body) { this.status = status; - this.message = message ?? `Error: ${status}`; + if (typeof body === 'string') { + this.body = { message: body }; + } else if (body) { + this.body = body; + } else { + this.body = { message: `Error: ${status}` }; + } } toString() { - return this.message; + return JSON.stringify(this.body); } } diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 25296344fca5..3f2c3654a5ba 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -2,7 +2,7 @@ import { HttpError, Redirect } from '../../control.js'; import { normalize_error } from '../../../utils/error.js'; import { once } from '../../../utils/functions.js'; import { load_server_data } from '../page/load_data.js'; -import { data_response, error_to_pojo } from '../utils.js'; +import { data_response, handle_error_and_jsonify } from '../utils.js'; import { normalize_path } from '../../../utils/url.js'; import { DATA_SUFFIX } from '../../../constants.js'; @@ -93,9 +93,7 @@ export async function render_data(event, route, options, state) { let length = promises.length; const nodes = await Promise.all( promises.map((p, i) => - p.catch((e) => { - const error = normalize_error(e); - + p.catch((error) => { if (error instanceof Redirect) { throw error; } @@ -103,18 +101,10 @@ export async function render_data(event, route, options, state) { // Math.min because array isn't guaranteed to resolve in order length = Math.min(length, i + 1); - if (error instanceof HttpError) { - return /** @type {import('types').ServerErrorNode} */ ({ - type: 'error', - httperror: { ...error } - }); - } - - options.handle_error(error, event); - return /** @type {import('types').ServerErrorNode} */ ({ type: 'error', - error: error_to_pojo(error, options.get_stack) + error: handle_error_and_jsonify(event, options, error), + status: error instanceof HttpError ? error.status : undefined }); }) ) @@ -140,7 +130,7 @@ export async function render_data(event, route, options, state) { return data_response(server_data); } else { // TODO make it clearer that this was an unexpected error - return data_response(error_to_pojo(error, options.get_stack)); + return data_response(handle_error_and_jsonify(event, options, error)); } } } diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index c9d2052f58ca..5511bcf00766 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -10,6 +10,7 @@ import { exec } from '../../utils/routing.js'; import { render_data } from './data/index.js'; import { DATA_SUFFIX } from '../../constants.js'; import { get_cookies } from './cookie.js'; +import { HttpError } from '../control.js'; /* global __SVELTEKIT_ADAPTER_NAME__ */ @@ -309,7 +310,8 @@ export async function respond(request, options, state) { // so we need to make an actual HTTP request return await fetch(request); } catch (e) { - const error = coalesce_to_error(e); + // HttpError can come from endpoint - TODO should it be handled there instead? + const error = e instanceof HttpError ? e : coalesce_to_error(e); return handle_fatal_error(event, options, error); } finally { event.cookies.set = () => { diff --git a/packages/kit/src/runtime/server/page/actions.js b/packages/kit/src/runtime/server/page/actions.js index c672a522ea74..6f9507e87279 100644 --- a/packages/kit/src/runtime/server/page/actions.js +++ b/packages/kit/src/runtime/server/page/actions.js @@ -2,7 +2,7 @@ import { error, json } from '../../../exports/index.js'; import { normalize_error } from '../../../utils/error.js'; import { negotiate } from '../../../utils/http.js'; import { HttpError, Redirect, ValidationError } from '../../control.js'; -import { error_to_pojo } from '../utils.js'; +import { handle_error_and_jsonify } from '../utils.js'; /** @param {import('types').RequestEvent} event */ export function is_action_json_request(event) { @@ -69,7 +69,7 @@ export async function handle_action_json_request(event, options, server) { return action_json( { type: 'error', - error: error_to_pojo(error, options.get_stack) + error: handle_error_and_jsonify(event, options, error) }, { status: error instanceof HttpError ? error.status : 500 diff --git a/packages/kit/src/runtime/server/page/index.js b/packages/kit/src/runtime/server/page/index.js index c85d233ead97..64e617ee0cb6 100644 --- a/packages/kit/src/runtime/server/page/index.js +++ b/packages/kit/src/runtime/server/page/index.js @@ -3,7 +3,12 @@ import { DATA_SUFFIX } from '../../../constants.js'; import { compact } from '../../../utils/array.js'; import { normalize_error } from '../../../utils/error.js'; import { HttpError, Redirect } from '../../control.js'; -import { get_option, redirect_response, static_error_page } from '../utils.js'; +import { + get_option, + redirect_response, + static_error_page, + handle_error_and_jsonify +} from '../utils.js'; import { handle_action_json_request, handle_action_request, @@ -197,13 +202,13 @@ export async function render_page(event, route, page, options, state, resolve_op branch.push({ node, server_data, data }); } catch (e) { - const error = normalize_error(e); + const err = normalize_error(e); - if (error instanceof Redirect) { + if (err instanceof Redirect) { if (state.prerendering && should_prerender_data) { const body = `window.__sveltekit_data = ${JSON.stringify({ type: 'redirect', - location: error.location + location: err.location })}`; state.prerendering.dependencies.set(data_pathname, { @@ -212,14 +217,11 @@ export async function render_page(event, route, page, options, state, resolve_op }); } - return redirect_response(error.status, error.location); - } - - if (!(error instanceof HttpError)) { - options.handle_error(/** @type {Error} */ (error), event); + return redirect_response(err.status, err.location); } - const status = error instanceof HttpError ? error.status : 500; + const status = err instanceof HttpError ? err.status : 500; + const error = handle_error_and_jsonify(event, options, err); while (i--) { if (page.errors[i]) { @@ -250,11 +252,7 @@ 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 error.html - return static_error_page( - options, - status, - /** @type {HttpError | Error} */ (error).message - ); + return static_error_page(options, status, error.message); } } else { // push an empty slot so we can rewind past gaps to the @@ -294,14 +292,12 @@ 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, or that a prerendering error occurred - options.handle_error(/** @type {Error} */ (error), event); - return await respond_with_error({ event, options, state, status: 500, - error: /** @type {Error} */ (error), + error, resolve_opts }); } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 2fb4ea7ba51b..5480db4ee1c2 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -5,8 +5,6 @@ import { hash } from '../../hash.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'; -import { HttpError } from '../../control.js'; // TODO rename this function/module @@ -25,7 +23,7 @@ const updated = { * state: import('types').SSRState; * page_config: { ssr: boolean; csr: boolean }; * status: number; - * error: HttpError | Error | null; + * error: App.PageError | null; * event: import('types').RequestEvent; * resolve_opts: import('types').RequiredResolveOptions; * action_result?: import('types').ActionResult; @@ -68,12 +66,6 @@ export async function render_response({ let rendered; - const stack = error instanceof HttpError ? undefined : error?.stack; - - if (error && options.dev && !(error instanceof HttpError)) { - error.stack = options.get_stack(error); - } - const form_value = action_result?.type === 'success' || action_result?.type === 'invalid' ? action_result.data ?? null @@ -203,7 +195,7 @@ export async function render_response({ env: ${s(options.public_env)}, hydrate: ${page_config.ssr ? `{ status: ${status}, - error: ${error && serialize_error(error, e => e.stack)}, + error: ${s(error)}, node_ids: [${branch.map(({ node }) => node.index).join(', ')}], params: ${devalue(event.params)}, routeId: ${s(event.routeId)}, @@ -347,11 +339,6 @@ export async function render_response({ } } - if (error && options.dev && !(error instanceof HttpError)) { - // reset stack, otherwise it may be 'fixed' a second time - error.stack = stack; - } - return new Response(html, { status, headers 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 d3ce83ffdd4e..b0e790f029a4 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,11 @@ 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, get_option, static_error_page } from '../utils.js'; +import { + handle_error_and_jsonify, + GENERIC_ERROR, + get_option, + static_error_page +} from '../utils.js'; import { create_fetch } from './fetch.js'; /** @@ -16,7 +20,7 @@ import { create_fetch } from './fetch.js'; * options: SSROptions; * state: SSRState; * status: number; - * error: Error; + * error: unknown; * resolve_opts: import('types').RequiredResolveOptions; * }} opts */ @@ -75,18 +79,14 @@ export async function respond_with_error({ event, options, state, status, error, csr: get_option([default_layout], 'csr') ?? true }, status, - error, + error: handle_error_and_jsonify(event, options, error), branch, fetched, cookies, event, resolve_opts }); - } catch (err) { - const error = coalesce_to_error(err); - - options.handle_error(error, event); - - return static_error_page(options, 500, error.message); + } catch (error) { + return static_error_page(options, 500, handle_error_and_jsonify(event, options, error).message); } } diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index 648baaf7bf05..7416f82aee62 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -33,9 +33,3 @@ export interface CspOpts { dev: boolean; prerender: boolean; } - -export interface SerializedHttpError extends Pick { - name: 'HttpError'; - stack: ''; - __is_http_error: true; -} diff --git a/packages/kit/src/runtime/server/utils.js b/packages/kit/src/runtime/server/utils.js index 0c1b662aada5..025823262b5e 100644 --- a/packages/kit/src/runtime/server/utils.js +++ b/packages/kit/src/runtime/server/utils.js @@ -21,57 +21,6 @@ export function is_pojo(body) { return true; } -/** - * Serialize an error into a JSON string through `error_to_pojo`. - * This is necessary because `JSON.stringify(error) === '{}'` - * - * @param {Error | HttpError} error - * @param {(error: Error) => string | undefined} get_stack - */ -export function serialize_error(error, get_stack) { - return JSON.stringify(error_to_pojo(error, get_stack)); -} - -/** - * Transform an error into a POJO, by copying its `name`, `message` - * and (in dev) `stack`, plus any custom properties, plus recursively - * serialized `cause` properties. - * Our own HttpError gets a meta property attached so we can identify it on the client. - * - * @param {HttpError | Error } error - * @param {(error: Error) => string | undefined} get_stack - */ -export function error_to_pojo(error, get_stack) { - if (error instanceof HttpError) { - return /** @type {import('./page/types').SerializedHttpError} */ ({ - message: error.message, - status: error.status, - __is_http_error: true // TODO we should probably make this unnecessary - }); - } - - const { - name, - message, - stack, - // @ts-expect-error i guess typescript doesn't know about error.cause yet - cause, - ...custom - } = error; - - /** @type {Record} */ - const object = { name, message, stack: get_stack(error) }; - - if (cause) object.cause = error_to_pojo(cause, get_stack); - - for (const key in custom) { - // @ts-expect-error - object[key] = custom[key]; - } - - return object; -} - // TODO: Remove for 1.0 /** @param {Record} mod */ export function check_method_names(mod) { @@ -181,16 +130,11 @@ export function static_error_page(options, status, message) { /** * @param {import('types').RequestEvent} event * @param {import('types').SSROptions} options - * @param {Error} error + * @param {Error | HttpError} 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); - } + const status = error instanceof HttpError ? error.status : 500; + const body = handle_error_and_jsonify(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', [ @@ -199,13 +143,27 @@ export function handle_fatal_error(event, options, error) { ]); if (event.url.pathname.endsWith(DATA_SUFFIX) || type === 'application/json') { - return new Response(serialize_error(error, options.get_stack), { + return new Response(JSON.stringify(body), { status, headers: { 'content-type': 'application/json; charset=utf-8' } }); } - return static_error_page(options, status, error.message); + return static_error_page(options, status, body.message); +} + +/** + * @param {import('types').RequestEvent} event + * @param {import('types').SSROptions} options + * @param {any} error + * @returns {App.PageError} + */ +export function handle_error_and_jsonify(event, options, error) { + if (error instanceof HttpError) { + return error.body; + } else { + return options.handle_error(error, event); + } } /** diff --git a/packages/kit/src/runtime/server/utils.spec.js b/packages/kit/src/runtime/server/utils.spec.js deleted file mode 100644 index 8ec55eefe025..000000000000 --- a/packages/kit/src/runtime/server/utils.spec.js +++ /dev/null @@ -1,45 +0,0 @@ -import { test } from 'uvu'; -import * as assert from 'uvu/assert'; -import { serialize_error } from './utils.js'; - -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(); diff --git a/packages/kit/src/utils/filesystem.js b/packages/kit/src/utils/filesystem.js index 5b907ca09bd1..8ddcc0c5c28a 100644 --- a/packages/kit/src/utils/filesystem.js +++ b/packages/kit/src/utils/filesystem.js @@ -106,3 +106,32 @@ export function walk(cwd, dirs = false) { export function posixify(str) { return str.replace(/\\/g, '/'); } + +/** + * Given an entry point like [cwd]/src/hooks, returns a filename like [cwd]/src/hooks.js or [cwd]/src/hooks/index.js + * @param {string} entry + * @returns {string|null} + */ +export function resolve_entry(entry) { + if (fs.existsSync(entry)) { + const stats = fs.statSync(entry); + if (stats.isDirectory()) { + return resolve_entry(path.join(entry, 'index')); + } + + return entry; + } else { + const dir = path.dirname(entry); + + if (fs.existsSync(dir)) { + const base = path.basename(entry); + const files = fs.readdirSync(dir); + + const found = files.find((file) => file.replace(/\.[^.]+$/, '') === base); + + if (found) return path.join(dir, found); + } + } + + return null; +} diff --git a/packages/kit/test/apps/amp/src/hooks.js b/packages/kit/test/apps/amp/src/hooks.server.js similarity index 100% rename from packages/kit/test/apps/amp/src/hooks.js rename to packages/kit/test/apps/amp/src/hooks.server.js diff --git a/packages/kit/test/apps/basics/src/hooks.client.js b/packages/kit/test/apps/basics/src/hooks.client.js new file mode 100644 index 000000000000..62ee8c1ebbff --- /dev/null +++ b/packages/kit/test/apps/basics/src/hooks.client.js @@ -0,0 +1,4 @@ +/** @type{import("@sveltejs/kit").HandleClientError} */ +export function handleError({ error }) { + return { message: /** @type {Error} */ (error).message }; +} diff --git a/packages/kit/test/apps/basics/src/hooks.js b/packages/kit/test/apps/basics/src/hooks.server.js similarity index 69% rename from packages/kit/test/apps/basics/src/hooks.js rename to packages/kit/test/apps/basics/src/hooks.server.js index 71885efa2d3c..f722b32b08d9 100644 --- a/packages/kit/test/apps/basics/src/hooks.js +++ b/packages/kit/test/apps/basics/src/hooks.server.js @@ -1,8 +1,29 @@ import fs from 'fs'; import { sequence } from '@sveltejs/kit/hooks'; +import { HttpError } from '../../../../src/runtime/control'; -/** @type {import('@sveltejs/kit').HandleError} */ -export const handleError = ({ event, error }) => { +/** + * Transform an error into a POJO, by copying its `name`, `message` + * and (in dev) `stack`, plus any custom properties, plus recursively + * serialized `cause` properties. + * + * @param {HttpError | Error } error + */ +export function error_to_pojo(error) { + if (error instanceof HttpError) { + return { + status: error.status, + ...error.body + }; + } + + const { name, message, stack, cause, ...custom } = error; + return { name, message, stack, ...custom }; +} + +/** @type {import('@sveltejs/kit').HandleServerError} */ +export const handleError = ({ event, error: e }) => { + const error = /** @type {Error} */ (e); // TODO we do this because there's no other way (that i'm aware of) // to communicate errors back to the test suite. even if we could // capture stderr, attributing an error to a specific request @@ -10,8 +31,9 @@ export const handleError = ({ event, error }) => { const errors = fs.existsSync('test/errors.json') ? JSON.parse(fs.readFileSync('test/errors.json', 'utf8')) : {}; - errors[event.url.pathname] = error.stack || error.message; + errors[event.url.pathname] = error_to_pojo(error); fs.writeFileSync('test/errors.json', JSON.stringify(errors)); + return { message: error.message }; }; export const handle = sequence( diff --git a/packages/kit/test/apps/basics/src/routes/+error.svelte b/packages/kit/test/apps/basics/src/routes/+error.svelte index d1897f37b61c..476d730cd604 100644 --- a/packages/kit/test/apps/basics/src/routes/+error.svelte +++ b/packages/kit/test/apps/basics/src/routes/+error.svelte @@ -10,8 +10,6 @@

This is your custom error page saying: "{$page.error.message}"

-
{$page.error.stack}
-