diff --git a/.changeset/young-beds-bathe.md b/.changeset/young-beds-bathe.md new file mode 100644 index 000000000000..00d00031be53 --- /dev/null +++ b/.changeset/young-beds-bathe.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Replace setup with hooks diff --git a/documentation/docs/01-routing.md b/documentation/docs/01-routing.md index 5113d066e679..f639df6050ea 100644 --- a/documentation/docs/01-routing.md +++ b/documentation/docs/01-routing.md @@ -39,10 +39,10 @@ Dynamic parameters are encoded using `[brackets]`. For example, a blog post migh ### Endpoints -Endpoints are modules written in `.js` (or `.ts`) files that export functions corresponding to HTTP methods. Each function receives HTTP `request` and `context` objects as arguments. For example our hypothetical blog page, `/blog/cool-article`, might request data from `/blog/cool-article.json`, which could be represented by a `src/routes/blog/[slug].json.js` endpoint: +Endpoints are modules written in `.js` (or `.ts`) files that export functions corresponding to HTTP methods. For example our hypothetical blog page, `/blog/cool-article`, might request data from `/blog/cool-article.json`, which could be represented by a `src/routes/blog/[slug].json.js` endpoint: ```ts -type Request = { +type Request = { host: string; method: 'GET'; headers: Record; @@ -50,6 +50,7 @@ type Request = { params: Record; query: URLSearchParams; body: string | Buffer | ReadOnlyFormData; + context: Context; // see getContext, below }; type Response = { @@ -57,6 +58,10 @@ type Response = { headers?: Record; body?: any; }; + +type RequestHandler = { + (request: Request) => Response | Promise; +} ``` ```js @@ -65,10 +70,10 @@ import db from '$lib/database'; /** * @type {import('@sveltejs/kit').RequestHandler} */ -export async function get(request, context) { +export async function get({ params }) { // the `slug` parameter is available because this file // is called [slug].json.js - const { slug } = request.params; + const { slug } = params; const article = await db.get(slug); @@ -86,14 +91,12 @@ export async function get(request, context) { Because this module only runs on the server (or when you build your site, if [prerendering](#prerendering)), you can freely access things like databases. (Don't worry about `$lib`, we'll get to that [later](#$lib).) -The second argument, `context`, is something you define during [setup](#setup), if necessary. - The job of this function is to return a `{status, headers, body}` object representing the response. If the returned `body` is an object, and no `content-type` header is returned, it will automatically be turned into a JSON response. For endpoints that handle other HTTP methods, like POST, export the corresponding function: ```js -export function post(request, context) {...} +export function post(request) {...} ``` Since `delete` is a reserved word in JavaScript, DELETE requests are handled with a `del` function. diff --git a/documentation/docs/03-loading.md b/documentation/docs/03-loading.md index fdbe987d70d4..61bf7217bcaa 100644 --- a/documentation/docs/03-loading.md +++ b/documentation/docs/03-loading.md @@ -83,7 +83,7 @@ So if the example above was `src/routes/blog/[slug].svelte` and the URL was `/bl #### session -`session` can be used to pass data from the server related to the current request, e.g. the current user. By default it is `undefined`. See [Setup](#setup) to learn how to use it. +`session` can be used to pass data from the server related to the current request, e.g. the current user. By default it is `undefined`. See [`getSession`](#hooks-getsession) to learn how to use it. #### context diff --git a/documentation/docs/04-hooks.md b/documentation/docs/04-hooks.md new file mode 100644 index 000000000000..feefa02f3b05 --- /dev/null +++ b/documentation/docs/04-hooks.md @@ -0,0 +1,117 @@ +--- +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 — **getContext**, **getSession** and **handle**. + +> The location of this file can be [configured](#configuration) as `config.kit.files.hooks` + +### getContext + +This function runs on every incoming request. It generates the `context` object that is available to [endpoint handlers](#routing-endpoints) as `request.context`, and used to derive the [`session`](#hooks-getsession) object available in the browser. + +If unimplemented, context is `{}`. + +```ts +type Incoming = { + method: string; + host: string; + headers: Headers; + path: string; + query: URLSearchParams; + body: string | Buffer | ReadOnlyFormData; +}; + +type GetContext = { + (incoming: Incoming): Context; +}; +``` + +```js +import * as cookie from 'cookie'; +import db from '$lib/db'; + +/** @type {import('@sveltejs/kit').GetContext} */ +export async function getContext({ headers }) { + const cookies = cookie.parse(headers.cookie || ''); + + return { + user: (await db.get_user(cookies.session_id)) || { guest: true } + }; +} +``` + +### getSession + +This function takes the [`context`](#hooks-getcontext) object and returns a `session` object that is safe to expose to the browser. It runs whenever SvelteKit renders a page. + +If unimplemented, session is `{}`. + +```ts +type GetSession = { + ({ context }: { context: Context }): Session | Promise; +}; +``` + +```js +/** @type {import('@sveltejs/kit').GetSession} */ +export function getSession({ context }) { + return { + user: { + // only include properties needed client-side — + // exclude anything else attached to the user + // like access tokens etc + name: context.user?.name, + email: context.user?.email, + avatar: context.user?.avatar + } + }; +} +``` + +> `session` must be serializable, which means it must not contain things like functions or custom classes, just built-in JavaScript data types + +### handle + +This function runs on every request, and determines the response. The second argument, `render`, calls SvelteKit's default renderer. This allows you to modify response headers or bodies, or bypass SvelteKit entirely (for implementing endpoints programmatically, for example). + +If unimplemented, defaults to `(request, render) => render(request)`. + +```ts +type Request = { + method: string; + host: string; + headers: Headers; + path: string; + params: Record; + query: URLSearchParams; + body: string | Buffer | ReadOnlyFormData; + context: Context; +}; + +type Response = { + status?: number; + headers?: Headers; + body?: any; +}; + +type Handle = ( + request: Request, + render: (request: Request) => Response | Promise +) => Response | Promise; +``` + +```js +/** @type {import('@sveltejs/kit').Handle} */ +export async function handle(request, render) { + const response = await render(request); + + return { + ...response, + headers: { + ...response.headers, + 'x-custom-header': 'potato' + } + }; +} +``` diff --git a/documentation/docs/04-setup.md b/documentation/docs/04-setup.md deleted file mode 100644 index e7ea17c8f44a..000000000000 --- a/documentation/docs/04-setup.md +++ /dev/null @@ -1,104 +0,0 @@ ---- -title: Setup ---- - -An optional `src/setup.js` (or `src/setup.ts`, or `src/setup/index.js`) file exports two functions that run on the server — **prepare** and **getSession**. - -Both functions, if provided, run for every page or endpoint request SvelteKit receives. - -> The location of this file can be [configured](#configuration) as `config.kit.files.setup` - -### prepare - -This function receives the incoming headers and can return `context` and outgoing `headers`: - -```js -/** - * @param {{ - * headers: Record - * }} incoming - * @returns {Promise<{ - * headers?: Record - * context?: any - * }>} - */ -export async function prepare({ headers }) { - return { - headers: {...}, - context: {...} - }; -} -``` - -The outgoing `headers` will be added to the response alongside any headers returned from individual endpoints (which take precedence). This is useful for setting cookies, for example: - -```js -import * as cookie from 'cookie'; -import { v4 as uuid } from '@lukeed/uuid'; - -export async function prepare(incoming) { - const cookies = cookie.parse(incoming.headers.cookie || ''); - - const headers = {}; - if (!cookies.session_id) { - headers['set-cookie'] = `session_id=${uuid()}; HttpOnly`; - } - - return { - headers - }; -} -``` - -The `context` is passed to endpoints, and is used by `getSession` to derive a session object which is available in the browser. It's a good place to store information about the current user, for example. - -```diff -import * as cookie from 'cookie'; -import { v4 as uuid } from '@lukeed/uuid'; -+import db from '$lib/db'; - -export async function prepare(incoming) { - const cookies = cookie.parse(incoming.headers.cookie || ''); - - const headers = {}; - if (!cookies.session_id) { - headers['set-cookie'] = `session_id=${uuid()}; HttpOnly`; - } - - return { -- headers -+ headers, -+ context: { -+ user: await db.get_user(cookies.session_id) -+ } - }; -} -``` - - -### getSession - -This function takes the `context` returned from `prepare` and returns a `session` object that is safe to expose to the browser. - -```js -/** - * @param {{ - * context: any - * }} options - * @returns {any} - */ -export function getSession({ context }) { - return { - user: { - // only include properties needed client-side — - // exclude anything else attached to the user - // like access tokens etc - name: context.user?.name, - email: context.user?.email, - avatar: context.user?.avatar - } - }; -} -``` - -> `session` must be serializable, which means it must not contain things like functions or custom classes, just built-in JavaScript data types \ No newline at end of file diff --git a/documentation/docs/05-modules.md b/documentation/docs/05-modules.md index 59ed225fd9f7..8e792134c0c3 100644 --- a/documentation/docs/05-modules.md +++ b/documentation/docs/05-modules.md @@ -10,9 +10,9 @@ SvelteKit makes a number of modules available to your application. import { amp, browser, dev } from '$app/env'; ``` -* `amp` is `true` or `false` depending on the corresponding value in your [project configuration](#configuration) -* `browser` is `true` or `false` depending on whether the app is running in the browser or on the server -* `dev` is `true` in development mode, `false` in production +- `amp` is `true` or `false` depending on the corresponding value in your [project configuration](#configuration) +- `browser` is `true` or `false` depending on whether the app is running in the browser or on the server +- `dev` is `true` in development mode, `false` in production ### $app/navigation @@ -20,9 +20,9 @@ import { amp, browser, dev } from '$app/env'; import { goto, prefetch, prefetchRoutes } from '$app/navigation'; ``` -* `goto(href, { replaceState, noscroll })` returns a `Promise` that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) to the specified `href`. The second argument is optional. If `replaceState` is true, a new history entry won't be created. If `noscroll` is true, the browser won't scroll to the top of the page after navigation. -* `prefetch(href)` programmatically prefetches the given page, which means a) ensuring that the code for the page is loaded, and b) calling the page's `load` function with the appropriate options. This is the same behaviour that SvelteKit triggers when the user taps or mouses over an `` element with [sveltekit:prefetch](docs#anchor-options-sveltekit-prefetch). If the next navigation is to `href`, the values returned from `load` will be used, making navigation instantaneous. Returns a `Promise` that resolves when the prefetch is complete. -* `prefetchRoutes(routes)` — programmatically prefetches the code for routes that haven't yet been fetched. Typically, you might call this to speed up subsequent navigation. If no argument is given, all routes will be fetched, otherwise you can specify routes by any matching pathname such as `/about` (to match `src/routes/about.svelte`) or `/blog/*` (to match `src/routes/blog/[slug].svelte`). Unlike `prefetch`, this won't call `preload` for individual pages. Returns a `Promise` that resolves when the routes have been prefetched. +- `goto(href, { replaceState, noscroll })` returns a `Promise` that resolves when SvelteKit navigates (or fails to navigate, in which case the promise rejects) to the specified `href`. The second argument is optional. If `replaceState` is true, a new history entry won't be created. If `noscroll` is true, the browser won't scroll to the top of the page after navigation. +- `prefetch(href)` programmatically prefetches the given page, which means a) ensuring that the code for the page is loaded, and b) calling the page's `load` function with the appropriate options. This is the same behaviour that SvelteKit triggers when the user taps or mouses over an `` element with [sveltekit:prefetch](docs#anchor-options-sveltekit-prefetch). If the next navigation is to `href`, the values returned from `load` will be used, making navigation instantaneous. Returns a `Promise` that resolves when the prefetch is complete. +- `prefetchRoutes(routes)` — programmatically prefetches the code for routes that haven't yet been fetched. Typically, you might call this to speed up subsequent navigation. If no argument is given, all routes will be fetched, otherwise you can specify routes by any matching pathname such as `/about` (to match `src/routes/about.svelte`) or `/blog/*` (to match `src/routes/blog/[slug].svelte`). Unlike `prefetch`, this won't call `preload` for individual pages. Returns a `Promise` that resolves when the routes have been prefetched. ### $app/paths @@ -30,8 +30,8 @@ import { goto, prefetch, prefetchRoutes } from '$app/navigation'; import { base, assets } from '$app/paths'; ``` -* `base` — a root-relative (i.e. begins with a `/`) string that matches `config.kit.files.base` in your [project configuration](#configuration) -* `assets` — a root-relative or absolute path that matches `config.kit.files.assets` (after it has been resolved against `base`) +- `base` — a root-relative (i.e. begins with a `/`) string that matches `config.kit.files.base` in your [project configuration](#configuration) +- `assets` — a root-relative or absolute path that matches `config.kit.files.assets` (after it has been resolved against `base`) ### $app/stores @@ -43,14 +43,13 @@ Stores are _contextual_ — they are added to the [context](https://svelte.dev/t Because of that, the stores are not free-floating objects: they must be accessed during component initialisation, like anything else that would be accessed with `getContext`. -* `getStores` is a convenience function around `getContext` that returns `{ navigating, page, session }`. Most of the time, you won't need to use it. +- `getStores` is a convenience function around `getContext` that returns `{ navigating, page, session }`. Most of the time, you won't need to use it. The stores themselves attach to the correct context at the point of subscription, which means you can import and use them directly in components without boilerplate. -* `navigating` is a [readable store](https://svelte.dev/tutorial/readable-stores). When navigating starts, its value is `{ from, to }`, where `from` and `to` both mirror the `page` store value. When navigating finishes, its value reverts to `null`. -* `page` is a readable store whose value reflects the object passed to `load` functions — it contains `host`, `path`, `params` and `query` -* `session` is a [writable store](https://svelte.dev/tutorial/writable-stores) whose initial value is whatever was returned from [`getSession`](#setup-getsession). It can be written to, but this will _not_ cause changes to persist on the server — this is something you must implement yourself. - +- `navigating` is a [readable store](https://svelte.dev/tutorial/readable-stores). When navigating starts, its value is `{ from, to }`, where `from` and `to` both mirror the `page` store value. When navigating finishes, its value reverts to `null`. +- `page` is a readable store whose value reflects the object passed to `load` functions — it contains `host`, `path`, `params` and `query` +- `session` is a [writable store](https://svelte.dev/tutorial/writable-stores) whose initial value is whatever was returned from [`getSession`](#hooks-getsession). It can be written to, but this will _not_ cause changes to persist on the server — this is something you must implement yourself. ### $lib @@ -64,6 +63,6 @@ This module is only available to [service workers](#service-workers). import { build, files, timestamp } from '$service-worker'; ``` -* `build` is an array of URL strings representing the files generated by Vite, suitable for caching with `cache.addAll(build)` -* `files` is an array of URL strings representing the files in your `static` directory, or whatever directory is specified by [`config.kit.files.assets`](#configuration) -* `timestamp` is the result of calling `Date.now()` at build time. It's useful for generating unique cache names inside your service worker, so that a later deployment of your app can invalidate old caches \ No newline at end of file +- `build` is an array of URL strings representing the files generated by Vite, suitable for caching with `cache.addAll(build)` +- `files` is an array of URL strings representing the files in your `static` directory, or whatever directory is specified by [`config.kit.files.assets`](#configuration) +- `timestamp` is the result of calling `Date.now()` at build time. It's useful for generating unique cache names inside your service worker, so that a later deployment of your app can invalidate old caches diff --git a/documentation/docs/13-configuration.md b/documentation/docs/13-configuration.md index 09da80947f82..b584b825b2f0 100644 --- a/documentation/docs/13-configuration.md +++ b/documentation/docs/13-configuration.md @@ -19,10 +19,10 @@ module.exports = { appDir: '_app', files: { assets: 'static', + hooks: 'src/hooks', lib: 'src/lib', routes: 'src/routes', serviceWorker: 'src/service-worker', - setup: 'src/setup', template: 'src/app.html' }, host: null, @@ -66,7 +66,7 @@ An object containing zero or more of the following `string` values: - `lib` — your app's internal library, accessible throughout the codebase as `$lib` - `routes` — the files that define the structure of your app (see [Routing](#routing)) - `serviceWorker` — the location of your service worker's entry point (see [Service workers](#service-workers)) -- `setup` — the location of your setup file (see [Setup](#setup)) +- `hooks` — the location of your hooks module (see [Hooks](#hooks)) - `template` — the location of the template for HTML responses #### host diff --git a/documentation/migrating/03-project-files.md b/documentation/migrating/03-project-files.md index 23a8a349ee4a..9df3d50ef357 100644 --- a/documentation/migrating/03-project-files.md +++ b/documentation/migrating/03-project-files.md @@ -18,16 +18,16 @@ This file has no equivalent in SvelteKit. Any custom logic (beyond `sapper.start ### src/server.js -This file also has no direct equivalent, since SvelteKit apps can run in serverless environments. You can, however, use the [setup module](/docs#setup) to implement session logic. +This file also has no direct equivalent, since SvelteKit apps can run in serverless environments. You can, however, use the [hooks module](/docs#hooks) to implement session logic. ### src/service-worker.js Most imports from `@sapper/service-worker` have equivalents in [`$service-worker`](/docs#modules-service-worker): -* `timestamp` is unchanged -* `files` is unchanged -* `shell` is now `build` -* `routes` has been removed +- `timestamp` is unchanged +- `files` is unchanged +- `shell` is now `build` +- `routes` has been removed ### src/template.html @@ -39,4 +39,4 @@ The `
` is no longer necessary, though you can continue mounting ### src/node_modules -A common pattern in Sapper apps is to put your internal library in a directory inside `src/node_modules`. This doesn't work with Vite, so we use [`src/lib`](/docs#modules-lib) instead. \ No newline at end of file +A common pattern in Sapper apps is to put your internal library in a directory inside `src/node_modules`. This doesn't work with Vite, so we use [`src/lib`](/docs#modules-lib) instead. diff --git a/examples/realworld.svelte.dev/src/setup/index.js b/examples/realworld.svelte.dev/src/hooks/index.js similarity index 78% rename from examples/realworld.svelte.dev/src/setup/index.js rename to examples/realworld.svelte.dev/src/hooks/index.js index b75253e0da69..f491d9c9dc5c 100644 --- a/examples/realworld.svelte.dev/src/setup/index.js +++ b/examples/realworld.svelte.dev/src/hooks/index.js @@ -1,14 +1,11 @@ import * as cookie from 'cookie'; -export function prepare({ headers }) { +export function getContext({ headers }) { const cookies = cookie.parse(headers.cookie || ''); const jwt = cookies.jwt && Buffer.from(cookies.jwt, 'base64').toString('utf-8'); return { - context: { - user: jwt ? JSON.parse(jwt) : null - }, - headers: {} + user: jwt ? JSON.parse(jwt) : null }; } diff --git a/examples/realworld.svelte.dev/src/routes/article/[slug]/comments/[id].json.js b/examples/realworld.svelte.dev/src/routes/article/[slug]/comments/[id].json.js index 7ab0ba68db5e..cbaab63eebbd 100644 --- a/examples/realworld.svelte.dev/src/routes/article/[slug]/comments/[id].json.js +++ b/examples/realworld.svelte.dev/src/routes/article/[slug]/comments/[id].json.js @@ -1,11 +1,11 @@ import * as api from '$lib/api.js'; -export async function del(request, context) { +export async function del({ params, context }) { if (!context.user) { return { status: 401 }; } - const { slug, id } = request.params; + const { slug, id } = params; const { status, error } = await api.del(`articles/${slug}/comments/${id}`, context.user.token); if (error) { diff --git a/examples/realworld.svelte.dev/src/routes/article/[slug]/comments/index.json.js b/examples/realworld.svelte.dev/src/routes/article/[slug]/comments/index.json.js index f9162f60d59f..60a128b880cc 100644 --- a/examples/realworld.svelte.dev/src/routes/article/[slug]/comments/index.json.js +++ b/examples/realworld.svelte.dev/src/routes/article/[slug]/comments/index.json.js @@ -1,7 +1,7 @@ import * as api from '$lib/api.js'; -export async function get(request, context) { - const { slug } = request.params; +export async function get({ params, context }) { + const { slug } = params; const { comments } = await api.get( `articles/${slug}/comments`, context.user && context.user.token @@ -12,13 +12,13 @@ export async function get(request, context) { }; } -export async function post(request, context) { +export async function post({ params, body: form, headers, context }) { if (!context.user) { return { status: 401 }; } - const { slug } = request.params; - const body = request.body.get('comment'); + const { slug } = params; + const body = form.get('comment'); const { comment } = await api.post( `articles/${slug}/comments`, @@ -27,7 +27,7 @@ export async function post(request, context) { ); // for AJAX requests, return the newly created comment - if (request.headers.accept === 'application/json') { + if (headers.accept === 'application/json') { return { status: 201, // created body: comment diff --git a/examples/realworld.svelte.dev/src/routes/article/[slug]/index.json.js b/examples/realworld.svelte.dev/src/routes/article/[slug]/index.json.js index 3ba84107bc69..51165bce6a9f 100644 --- a/examples/realworld.svelte.dev/src/routes/article/[slug]/index.json.js +++ b/examples/realworld.svelte.dev/src/routes/article/[slug]/index.json.js @@ -1,7 +1,7 @@ import * as api from '$lib/api.js'; -export async function get(request, context) { - const { slug } = request.params; +export async function get({ params, context }) { + const { slug } = params; const { article } = await api.get(`articles/${slug}`, context.user && context.user.token); return { @@ -9,6 +9,6 @@ export async function get(request, context) { }; } -export async function put(request, context) { +export async function put(request) { console.log('put', request); } diff --git a/examples/realworld.svelte.dev/src/routes/article/index.json.js b/examples/realworld.svelte.dev/src/routes/article/index.json.js index e4fe52e7d6c6..c6a3cec3e7a0 100644 --- a/examples/realworld.svelte.dev/src/routes/article/index.json.js +++ b/examples/realworld.svelte.dev/src/routes/article/index.json.js @@ -1 +1 @@ -export async function post(request, context) {} +export async function post(request) {} diff --git a/examples/realworld.svelte.dev/src/routes/articles.json.js b/examples/realworld.svelte.dev/src/routes/articles.json.js index 1ccc7f0e8029..ce0f5d9fb8ae 100644 --- a/examples/realworld.svelte.dev/src/routes/articles.json.js +++ b/examples/realworld.svelte.dev/src/routes/articles.json.js @@ -1,7 +1,7 @@ import * as api from '$lib/api'; import { page_size } from '$lib/constants'; -export async function get(request, context) { +export async function get({ query, context }) { const tab = request.query.get('tab') || 'all'; const tag = request.query.get('tag'); const page = +request.query.get('page') || 1; diff --git a/examples/realworld.svelte.dev/src/routes/auth/save.js b/examples/realworld.svelte.dev/src/routes/auth/save.js index 1d3aa8558f2e..8b5bf7d85ea0 100644 --- a/examples/realworld.svelte.dev/src/routes/auth/save.js +++ b/examples/realworld.svelte.dev/src/routes/auth/save.js @@ -1,9 +1,7 @@ import * as api from '$lib/api.js'; import { respond } from './_respond'; -export async function post(request, context) { - const user = request.body; - +export async function post({ body: user, context }) { if (!context.user) { return { status: 401 diff --git a/examples/realworld.svelte.dev/src/routes/profile/@[user]/_get_articles.js b/examples/realworld.svelte.dev/src/routes/profile/@[user]/_get_articles.js index 770e3c48078a..d0479d91069b 100644 --- a/examples/realworld.svelte.dev/src/routes/profile/@[user]/_get_articles.js +++ b/examples/realworld.svelte.dev/src/routes/profile/@[user]/_get_articles.js @@ -1,13 +1,13 @@ import * as api from '$lib/api.js'; import { page_size } from '$lib/constants.js'; -export async function get_articles(request, context, type) { - const p = +request.query.get('page') || 1; +export async function get_articles({ query, params, context }, type) { + const p = +query.get('page') || 1; const q = new URLSearchParams([ ['limit', page_size], ['offset', (p - 1) * page_size], - [type, encodeURIComponent(request.params.user)] + [type, encodeURIComponent(params.user)] ]); const { articles, articlesCount } = await api.get( diff --git a/examples/realworld.svelte.dev/src/routes/profile/@[user]/articles.json.js b/examples/realworld.svelte.dev/src/routes/profile/@[user]/articles.json.js index 11d0f33cace0..8ed72476a7ce 100644 --- a/examples/realworld.svelte.dev/src/routes/profile/@[user]/articles.json.js +++ b/examples/realworld.svelte.dev/src/routes/profile/@[user]/articles.json.js @@ -1,5 +1,5 @@ import { get_articles } from './_get_articles'; -export async function get(request, context) { - return get_articles(request, context, 'author'); +export async function get(request) { + return get_articles(request, 'author'); } diff --git a/examples/realworld.svelte.dev/src/routes/profile/@[user]/favorites.json.js b/examples/realworld.svelte.dev/src/routes/profile/@[user]/favorites.json.js index d4c0b4f99951..53a51fcdb13d 100644 --- a/examples/realworld.svelte.dev/src/routes/profile/@[user]/favorites.json.js +++ b/examples/realworld.svelte.dev/src/routes/profile/@[user]/favorites.json.js @@ -1,5 +1,5 @@ import { get_articles } from './_get_articles'; -export async function get(request, context) { - return get_articles(request, context, 'favorited'); +export async function get(request) { + return get_articles(request, 'favorited'); } diff --git a/examples/realworld.svelte.dev/src/routes/profile/@[user]/follow.js b/examples/realworld.svelte.dev/src/routes/profile/@[user]/follow.js index 2dea1be015f5..b199c73ee7a9 100644 --- a/examples/realworld.svelte.dev/src/routes/profile/@[user]/follow.js +++ b/examples/realworld.svelte.dev/src/routes/profile/@[user]/follow.js @@ -1,13 +1,13 @@ import * as api from '$lib/api.js'; -export async function post(request, context) { +export async function post({ params, context }) { return { - body: await api.post(`profiles/${request.params.user}/follow`, null, context.user.token) + body: await api.post(`profiles/${params.user}/follow`, null, context.user.token) }; } -export async function del(request, context) { +export async function del({ params, context }) { return { - body: await api.del(`profiles/${request.params.user}/follow`, context.user.token) + body: await api.del(`profiles/${params.user}/follow`, context.user.token) }; } diff --git a/examples/realworld.svelte.dev/src/routes/profile/@[user]/index.json.js b/examples/realworld.svelte.dev/src/routes/profile/@[user]/index.json.js index 6384fa7ea069..fd3f5ce759ae 100644 --- a/examples/realworld.svelte.dev/src/routes/profile/@[user]/index.json.js +++ b/examples/realworld.svelte.dev/src/routes/profile/@[user]/index.json.js @@ -1,10 +1,7 @@ import * as api from '$lib/api.js'; -export async function get(request, context) { - const { profile } = await api.get( - `profiles/${request.params.user}`, - context.user && context.user.token - ); +export async function get({ params, context }) { + const { profile } = await api.get(`profiles/${params.user}`, context.user && context.user.token); return { body: profile diff --git a/examples/sandbox/src/hooks/index.js b/examples/sandbox/src/hooks/index.js new file mode 100644 index 000000000000..7310ef66c705 --- /dev/null +++ b/examples/sandbox/src/hooks/index.js @@ -0,0 +1,9 @@ +export function getContext({ headers }) { + return { + answer: 42 + }; +} + +export function getSession({ context }) { + return context; +} diff --git a/examples/sandbox/src/setup/index.js b/examples/sandbox/src/setup/index.js deleted file mode 100644 index d91b52c284fa..000000000000 --- a/examples/sandbox/src/setup/index.js +++ /dev/null @@ -1,14 +0,0 @@ -export function prepare({ headers }) { - return { - context: { - answer: 42 - }, - headers: { - 'x-foo': 'banana' - } - }; -} - -export function getSession({ context }) { - return context; -} diff --git a/examples/svelte-kit-demo/src/setup/index.js b/examples/svelte-kit-demo/src/hooks/index.js similarity index 100% rename from examples/svelte-kit-demo/src/setup/index.js rename to examples/svelte-kit-demo/src/hooks/index.js diff --git a/packages/kit/src/core/build/index.js b/packages/kit/src/core/build/index.js index 645d6ddea5d0..7f3a6899077d 100644 --- a/packages/kit/src/core/build/index.js +++ b/packages/kit/src/core/build/index.js @@ -2,7 +2,7 @@ import fs from 'fs'; import path from 'path'; import { rimraf } from '../filesystem/index.js'; import create_manifest_data from '../../core/create_manifest_data/index.js'; -import { copy_assets } from '../utils.js'; +import { copy_assets, resolve_entry } from '../utils.js'; import { create_app } from '../../core/create_app/index.js'; import vite from 'vite'; import svelte from '@sveltejs/vite-plugin-svelte'; @@ -181,10 +181,10 @@ async function build_server( client_manifest, runtime ) { - let setup_file = resolve_entry(config.kit.files.setup); - if (!fs.existsSync(setup_file)) { - setup_file = path.resolve(cwd, '.svelte/build/setup.js'); - fs.writeFileSync(setup_file, ''); + let hooks_file = resolve_entry(config.kit.files.hooks); + if (!fs.existsSync(hooks_file)) { + hooks_file = path.resolve(cwd, '.svelte/build/hooks.js'); + fs.writeFileSync(hooks_file, ''); } const app_file = `${build_dir}/app.js`; @@ -275,7 +275,7 @@ async function build_server( import { ssr } from '${runtime}'; import root from './generated/root.svelte'; import { set_paths } from './runtime/paths.js'; - import * as setup from ${s(app_relative(setup_file))}; + import * as user_hooks from ${s(app_relative(hooks_file))}; const template = ({ head, body }) => ${s(fs.readFileSync(config.kit.files.template, 'utf-8')) .replace('%svelte.head%', '" + head + "') @@ -348,6 +348,14 @@ async function build_server( ] }; + const get_hooks = hooks => ({ + getContext: hooks.getContext || (() => ({})), + getSession: hooks.getSession || (() => ({})), + handle: hooks.handle || ((request, render) => render(request)) + }); + + const hooks = get_hooks(user_hooks); + export function render(request, { paths = ${s(config.kit.paths)}, local = false, @@ -365,7 +373,7 @@ async function build_server( target: ${s(config.kit.target)}, entry: ${s(entry)}, root, - setup, + hooks, dev: false, amp: ${config.kit.amp}, only_render_prerenderable_pages, @@ -519,34 +527,6 @@ async function build_service_worker( }); } -/** - * @param {string} entry - * @returns {string} - */ -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; -} - /** @param {string[]} array */ function get_params(array) { // given an array of params like `['x', 'y', 'z']` for diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index ad8a4f9d30e7..013c22daa575 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -118,9 +118,9 @@ class Watcher extends EventEmitter { // handle dynamic requests - i.e. pages and endpoints const template = fs.readFileSync(this.config.kit.files.template, 'utf-8'); - const setup = await this.viteDevServer - .ssrLoadModule(`/${this.config.kit.files.setup}`) - .catch(() => ({})); + const hooks = /** @type {import('../../../types.internal').Hooks} */ (await this.viteDevServer + .ssrLoadModule(`/${this.config.kit.files.hooks}`) + .catch(() => ({}))); let root; @@ -206,7 +206,11 @@ class Watcher extends EventEmitter { dev: true, amp: this.config.kit.amp, root, - setup, + hooks: { + getContext: hooks.getContext || (() => ({})), + getSession: hooks.getSession || (() => ({})), + handle: hooks.handle || ((request, render) => render(request)) + }, only_render_prerenderable_pages: false, get_component_path: (id) => `/${id}?import`, get_stack: (error) => { diff --git a/packages/kit/src/core/load_config/index.js b/packages/kit/src/core/load_config/index.js index 7455ef7844cb..bff9a16aacca 100644 --- a/packages/kit/src/core/load_config/index.js +++ b/packages/kit/src/core/load_config/index.js @@ -1,6 +1,7 @@ import options from './options.js'; import * as url from 'url'; import path from 'path'; +import { resolve_entry } from '../utils.js'; /** @typedef {import('./types').ConfigDefinition} ConfigDefinition */ @@ -85,12 +86,20 @@ export async function load_config({ cwd = process.cwd() } = {}) { const validated = validate_config(config.default); validated.kit.files.assets = path.resolve(cwd, validated.kit.files.assets); + validated.kit.files.hooks = path.resolve(cwd, validated.kit.files.hooks); validated.kit.files.lib = path.resolve(cwd, validated.kit.files.lib); validated.kit.files.routes = path.resolve(cwd, validated.kit.files.routes); validated.kit.files.serviceWorker = path.resolve(cwd, validated.kit.files.serviceWorker); validated.kit.files.setup = path.resolve(cwd, validated.kit.files.setup); validated.kit.files.template = path.resolve(cwd, validated.kit.files.template); + // TODO remove this, eventually + if (resolve_entry(validated.kit.files.setup)) { + throw new Error( + 'config.kit.files.setup has been replaced with config.kit.files.hooks. See https://kit.svelte.dev/docs#hooks' + ); + } + // TODO check all the `files` exist when the config is loaded? // TODO check that `target` is present in the provided template diff --git a/packages/kit/src/core/load_config/index.spec.js b/packages/kit/src/core/load_config/index.spec.js index 67ff9b8d8f1a..840a28c87c88 100644 --- a/packages/kit/src/core/load_config/index.spec.js +++ b/packages/kit/src/core/load_config/index.spec.js @@ -16,6 +16,7 @@ test('fills in defaults', () => { appDir: '_app', files: { assets: 'static', + hooks: 'src/hooks', lib: 'src/lib', routes: 'src/routes', serviceWorker: 'src/service-worker', @@ -92,6 +93,7 @@ test('fills in partial blanks', () => { appDir: '_app', files: { assets: 'public', + hooks: 'src/hooks', lib: 'src/lib', routes: 'src/routes', serviceWorker: 'src/service-worker', diff --git a/packages/kit/src/core/load_config/options.js b/packages/kit/src/core/load_config/options.js index 6282debc83e4..5e343b3417c8 100644 --- a/packages/kit/src/core/load_config/options.js +++ b/packages/kit/src/core/load_config/options.js @@ -62,9 +62,11 @@ const options = { type: 'branch', children: { assets: expect_string('static'), + hooks: expect_string('src/hooks'), lib: expect_string('src/lib'), routes: expect_string('src/routes'), serviceWorker: expect_string('src/service-worker'), + // TODO remove this, eventually setup: expect_string('src/setup'), template: expect_string('src/app.html') } diff --git a/packages/kit/src/core/load_config/test/index.js b/packages/kit/src/core/load_config/test/index.js index 15e64d398f91..7a618d6ef331 100644 --- a/packages/kit/src/core/load_config/test/index.js +++ b/packages/kit/src/core/load_config/test/index.js @@ -25,6 +25,7 @@ suite('load default config', async () => { appDir: '_app', files: { assets: join(cwd, 'static'), + hooks: join(cwd, 'src/hooks'), lib: join(cwd, 'src/lib'), routes: join(cwd, 'src/routes'), serviceWorker: join(cwd, 'src/service-worker'), diff --git a/packages/kit/src/core/utils.js b/packages/kit/src/core/utils.js index 41269abace53..c9d53a0231de 100644 --- a/packages/kit/src/core/utils.js +++ b/packages/kit/src/core/utils.js @@ -1,11 +1,11 @@ -import { dirname, resolve } from 'path'; +import fs from 'fs'; +import path from 'path'; import colors from 'kleur'; import { copy } from './filesystem/index.js'; import { fileURLToPath } from 'url'; -import { existsSync } from 'fs'; const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); +const __dirname = path.dirname(__filename); /** @param {string} dest */ export function copy_assets(dest) { @@ -13,9 +13,9 @@ export function copy_assets(dest) { do { // we jump through these hoops so that this function // works whether or not it's been bundled - const resolved = resolve(__dirname, `${prefix}/assets`); + const resolved = path.resolve(__dirname, `${prefix}/assets`); - if (existsSync(resolved)) { + if (fs.existsSync(resolved)) { copy(resolved, dest); return; } @@ -40,3 +40,32 @@ export function logger({ verbose }) { return log; } + +/** + * 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} + */ +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/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 9eb0aacb45e7..154256e85d28 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -1,11 +1,9 @@ /** - * @param {import('types.internal').Request} request + * @param {import('types').Request} request * @param {import('types.internal').SSREndpoint} route - * @param {any} context - * @param {import('types.internal').SSRRenderOptions} options - * @returns {Promise} + * @returns {Promise} */ -export default async function render_route(request, route, context, options) { +export default async function render_route(request, route) { const mod = await route.load(); /** @type {import('types').RequestHandler} */ @@ -15,17 +13,7 @@ export default async function render_route(request, route, context, options) { const match = route.pattern.exec(request.path); const params = route.params(match); - const response = await handler( - { - host: request.host, - path: request.path, - headers: request.headers, - query: request.query, - body: request.body, - params - }, - context - ); + const response = await handler({ ...request, params }); if (response) { if (typeof response !== 'object' || response.body == null) { diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index a013eadd914a..d81c3e47735a 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -8,63 +8,64 @@ function md5(body) { } /** - * @param {import('../../../types.internal').Request} request + * @param {import('../../../types').Incoming} incoming * @param {import('../../../types.internal').SSRRenderOptions} options */ -export async function ssr(request, options) { - if (request.path.endsWith('/') && request.path !== '/') { - const q = request.query.toString(); +export async function ssr(incoming, options) { + if (incoming.path.endsWith('/') && incoming.path !== '/') { + const q = incoming.query.toString(); return { status: 301, headers: { - location: request.path.slice(0, -1) + (q ? `?${q}` : '') + location: incoming.path.slice(0, -1) + (q ? `?${q}` : '') } }; } - const { context, headers = {} } = - (await (options.setup.prepare && options.setup.prepare({ headers: request.headers }))) || {}; + const context = (await options.hooks.getContext(incoming)) || {}; try { - for (const route of options.manifest.routes) { - if (route.pattern.test(request.path)) { - const response = - route.type === 'endpoint' - ? await render_endpoint(request, route, context, options) - : await render_page(request, route, context, options); + return await options.hooks.handle( + { + ...incoming, + params: null, + context + }, + async (request) => { + for (const route of options.manifest.routes) { + if (!route.pattern.test(request.path)) continue; - if (response) { - // inject ETags for 200 responses - if (response.status === 200) { - if (!/(no-store|immutable)/.test(response.headers['cache-control'])) { - const etag = `"${md5(response.body)}"`; + const response = + route.type === 'endpoint' + ? await render_endpoint(request, route) + : await render_page(request, route, options); - if (request.headers['if-none-match'] === etag) { - return { - status: 304, - headers: {}, - body: null - }; - } + if (response) { + // inject ETags for 200 responses + if (response.status === 200) { + if (!/(no-store|immutable)/.test(response.headers['cache-control'])) { + const etag = `"${md5(response.body)}"`; + + if (request.headers['if-none-match'] === etag) { + return { + status: 304, + headers: {}, + body: null + }; + } - response.headers['etag'] = etag; + response.headers['etag'] = etag; + } } - } - return { - status: response.status, - // TODO header merging is more involved than this — see the 'message.headers' - // section of https://nodejs.org/api/http.html#http_class_http_incomingmessage - headers: { ...headers, ...response.headers }, - body: response.body, - dependencies: response.dependencies - }; + return response; + } } - } - } - return await render_page(request, null, context, options); + return await render_page(request, null, options); + } + ); } catch (e) { if (e && e.stack) { e.stack = await options.get_stack(e); @@ -74,7 +75,7 @@ export async function ssr(request, options) { return { status: 500, - headers, + headers: {}, body: options.dev ? e.stack : e.message }; } diff --git a/packages/kit/src/runtime/server/page.js b/packages/kit/src/runtime/server/page.js index bca7f7e6275f..b31dd45492cc 100644 --- a/packages/kit/src/runtime/server/page.js +++ b/packages/kit/src/runtime/server/page.js @@ -9,17 +9,17 @@ const s = JSON.stringify; /** * @param {{ - * request: import('types.internal').Request; + * request: import('types').Request; * options: import('types.internal').SSRRenderOptions; * $session: any; * route: import('types.internal').SSRPage; * status: number; * error: Error * }} opts - * @returns {Promise} + * @returns {Promise} */ async function get_response({ request, options, $session, route, status = 200, error }) { - /** @type {Record} */ + /** @type {Record} */ const dependencies = {}; const serialized_session = try_serialize($session, (error) => { @@ -132,7 +132,7 @@ async function get_response({ request, options, $session, route, status = 200, e method: opts.method || 'GET', headers: /** @type {import('types.internal').Headers} */ (opts.headers || {}), // TODO inject credentials... path: resolved, - body: opts.body, + body: /** @type {any} */ (opts.body), query: new URLSearchParams(parsed.query || '') }, { @@ -435,13 +435,12 @@ async function get_response({ request, options, $session, route, status = 200, e } /** - * @param {import('types.internal').Request} request + * @param {import('types').Request} request * @param {import('types.internal').SSRPage} route - * @param {any} context * @param {import('types.internal').SSRRenderOptions} options - * @returns {Promise} + * @returns {Promise} */ -export default async function render_page(request, route, context, options) { +export default async function render_page(request, route, options) { if (options.initiator === route) { // infinite request cycle detected return { @@ -451,7 +450,7 @@ export default async function render_page(request, route, context, options) { }; } - const $session = await (options.setup.getSession && options.setup.getSession({ context })); + const $session = await options.hooks.getSession({ context: request.context }); const response = await get_response({ request, diff --git a/packages/kit/test/apps/basics/src/setup.js b/packages/kit/test/apps/basics/src/hooks.js similarity index 64% rename from packages/kit/test/apps/basics/src/setup.js rename to packages/kit/test/apps/basics/src/hooks.js index cefef93bd593..6dde9e6ba1d3 100644 --- a/packages/kit/test/apps/basics/src/setup.js +++ b/packages/kit/test/apps/basics/src/hooks.js @@ -1,8 +1,6 @@ -export function prepare() { +export function getContext() { return { - context: { - answer: 42 - } + answer: 42 }; } diff --git a/packages/kit/test/apps/basics/src/routes/errors/__tests__.js b/packages/kit/test/apps/basics/src/routes/errors/__tests__.js index 7d81cc9c1660..57f651cc3930 100644 --- a/packages/kit/test/apps/basics/src/routes/errors/__tests__.js +++ b/packages/kit/test/apps/basics/src/routes/errors/__tests__.js @@ -183,9 +183,12 @@ export default function (test, is_dev) { assert.match(await res.text(), /PUT is not implemented/); }); - test('error in endpoint', async ({ base, page }) => { + test('error in endpoint', null, async ({ base, page }) => { + /** @type {string[]} */ const console_errors = []; const { error: original_error } = console; + + /** @param {string} text */ console.error = (text) => { console_errors.push(text); }; diff --git a/packages/kit/types.d.ts b/packages/kit/types.d.ts index 39c8a7e6dfb2..7c675d99bfca 100644 --- a/packages/kit/types.d.ts +++ b/packages/kit/types.d.ts @@ -9,10 +9,10 @@ export type Config = { appDir?: string; files?: { assets?: string; + hooks?: string; lib?: string; routes?: string; serviceWorker?: string; - setup?: string; template?: string; }; host?: string; @@ -55,22 +55,45 @@ interface ReadOnlyFormData extends Iterator<[string, string]> { values: () => Iterator; } -export interface RequestHandlerResponse { +export type Incoming = { + method: string; + host: string; + headers: Headers; + path: string; + query: URLSearchParams; + body: string | Buffer | ReadOnlyFormData; +}; + +export type Request = { + method: string; + host: string; + headers: Headers; + path: string; + params: Record; + query: URLSearchParams; + body: string | Buffer | ReadOnlyFormData; + context: Context; +}; + +export type Response = { status?: number; - headers?: Record; + headers?: Headers; body?: any; -} +}; -export type RequestHandler = ( - request?: { - host: string; - headers: Headers; - path: string; - params: Record; - query: URLSearchParams; - body: string | Buffer | ReadOnlyFormData; - }, - context?: any -) => RequestHandlerResponse | Promise; +export type RequestHandler = ( + request?: Request +) => Response | Promise; export type Load = (input: LoadInput) => LoadOutput | Promise; + +export type GetContext = (incoming: Incoming) => Context; + +export type GetSession = { + ({ context }: { context: Context }): Session | Promise; +}; + +export type Handle = ( + request: Request, + render: (request: Request) => Response | Promise +) => Response | Promise; diff --git a/packages/kit/types.internal.d.ts b/packages/kit/types.internal.d.ts index 4325d4a72c88..a5f936315ee5 100644 --- a/packages/kit/types.internal.d.ts +++ b/packages/kit/types.internal.d.ts @@ -1,4 +1,4 @@ -import { Adapter, Load } from './types'; +import { Adapter, GetContext, GetSession, Handle, Incoming, Load, Response } from './types'; declare global { interface ImportMeta { @@ -24,6 +24,7 @@ export type ValidatedConfig = { appDir: string; files: { assets: string; + hooks: string; lib: string; routes: string; serviceWorker: string; @@ -57,7 +58,7 @@ export type App = { assets: string; }; }) => void; - render: (request: Request, options: SSRRenderOptions) => SKResponse; + render: (incoming: Incoming, options: SSRRenderOptions) => ResponseWithDependencies; }; // TODO we want to differentiate between request headers, which @@ -66,20 +67,8 @@ export type App = { // but this can't happen until TypeScript 4.3 export type Headers = Record; -export type Request = { - host: string; - method: string; - headers: Headers; - path: string; - body: any; - query: URLSearchParams; -}; - -export type SKResponse = { - status: number; - headers: Headers; - body?: any; - dependencies?: Record; +export type ResponseWithDependencies = Response & { + dependencies?: Record; }; export type Page = { @@ -165,6 +154,12 @@ export type SSRManifest = { routes: SSRRoute[]; }; +export type Hooks = { + getContext?: GetContext; + getSession?: GetSession; + handle?: Handle; +}; + // TODO separate out runtime options from the ones fixed in dev/build export type SSRRenderOptions = { paths?: { @@ -177,15 +172,7 @@ export type SSRRenderOptions = { target?: string; entry?: string; root?: SSRComponent['default']; - setup?: { - prepare?: (incoming: { - headers: Headers; - }) => { - context?: any; - headers?: Headers; - }; - getSession?: ({ context }: { context: any }) => any; - }; + hooks?: Hooks; dev?: boolean; amp?: boolean; only_render_prerenderable_pages?: boolean;