From 95c9eefd032386765151f7a3c4cc56ee35845929 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Tue, 13 Sep 2022 16:48:16 +0200 Subject: [PATCH 1/8] [feat] allow +server.js files next to +page files Closes #5896 --- .changeset/lazy-mice-remain.md | 5 +++ documentation/docs/06-form-actions.md | 34 +++++++++++++++++++ .../core/sync/create_manifest_data/index.js | 5 --- packages/kit/src/runtime/server/endpoint.js | 22 ++++++++++++ packages/kit/src/runtime/server/index.js | 6 ++-- .../endpoint-next-to-page/+page.svelte | 18 ++++++++++ .../routing/endpoint-next-to-page/+server.js | 24 +++++++++++++ .../kit/test/apps/basics/test/client.test.js | 22 +++++++++++- 8 files changed, 127 insertions(+), 9 deletions(-) create mode 100644 .changeset/lazy-mice-remain.md create mode 100644 packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+page.svelte create mode 100644 packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+server.js diff --git a/.changeset/lazy-mice-remain.md b/.changeset/lazy-mice-remain.md new file mode 100644 index 000000000000..519d0d7fd63d --- /dev/null +++ b/.changeset/lazy-mice-remain.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +[feat] allow +server.js files next to +page files diff --git a/documentation/docs/06-form-actions.md b/documentation/docs/06-form-actions.md index 32eae3d119ad..64d1da90e779 100644 --- a/documentation/docs/06-form-actions.md +++ b/documentation/docs/06-form-actions.md @@ -349,3 +349,37 @@ We can also implement progressive enhancement ourselves, without `use:enhance`, ``` + +### Alternatives + +Form actions are specifically designed to also work without JavaScript, which means you need to use forms for it. If you don't need the page to work without JavaScript and/or you for example want to interact with an API through JSON, you can instead use `+server.js` files: + +```svelte +/// file: src/routes/crud/+page.svelte + + + +

Result: {JSON.stringify(result)}

+``` + +```js +/// file: src/routes/crud/+server.js +import { json } from '@sveltejs/kit'; + +/** @type {import('./$types').RequestHandler} */ +export function PUT() { + // ... + return json({ new: 'value' }); +} +``` diff --git a/packages/kit/src/core/sync/create_manifest_data/index.js b/packages/kit/src/core/sync/create_manifest_data/index.js index c386bf1ed6f1..563c391b02ab 100644 --- a/packages/kit/src/core/sync/create_manifest_data/index.js +++ b/packages/kit/src/core/sync/create_manifest_data/index.js @@ -272,11 +272,6 @@ function create_routes_and_nodes(cwd, config, fallback) { route_map.forEach((route) => { if (!route.leaf) return; - if (route.leaf && route.endpoint) { - // TODO possibly relax this https://github.com/sveltejs/kit/issues/5896 - throw new Error(`${route.endpoint.file} cannot share a directory with other route files`); - } - route.page = { layouts: [], errors: [], diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 5e3aba0840e4..964ce834b59b 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -1,4 +1,5 @@ import { json } from '../../exports/index.js'; +import { negotiate } from '../../utils/http.js'; import { Redirect, ValidationError } from '../control.js'; import { check_method_names, method_not_allowed } from './utils.js'; @@ -64,3 +65,24 @@ export async function render_endpoint(event, mod, state) { throw error; } } + +/** + * @param {import('types').RequestEvent} event + */ +export function is_endpoint_request(event) { + const result = + // These only exist for +server + ['PUT', 'PATCH', 'DELETE'].includes(event.request.method) || + // GET has accept text/html for pages + (event.request.method === 'GET' && + negotiate(event.request.headers.get('accept') ?? '*/*', ['application/json', 'text/html']) === + 'application/json') || + // POST with FormData is for actions + (event.request.method === 'POST' && + !(event.request.headers.get('content-type') ?? '') + .split(';') + .some((part) => + ['multipart/form-data', 'application/x-www-form-urlencoded'].includes(part.trim()) + )); + return result; +} diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index e12128c9011a..7c2f495dcfaa 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,5 +1,5 @@ import * as cookie from 'cookie'; -import { render_endpoint } from './endpoint.js'; +import { is_endpoint_request, render_endpoint } from './endpoint.js'; import { render_page } from './page/index.js'; import { render_response } from './page/render.js'; import { respond_with_error } from './page/respond_with_error.js'; @@ -227,10 +227,10 @@ export async function respond(request, options, state) { if (is_data_request) { response = await render_data(event, route, options, state); + } else if (route.endpoint && (!route.page || is_endpoint_request(event))) { + response = await render_endpoint(event, await route.endpoint(), state); } else if (route.page) { response = await render_page(event, route, route.page, options, state, resolve_opts); - } else if (route.endpoint) { - response = await render_endpoint(event, await route.endpoint(), state); } else { // a route will always have a page or an endpoint, but TypeScript // doesn't know that diff --git a/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+page.svelte b/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+page.svelte new file mode 100644 index 000000000000..b88ab6af7f8f --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+page.svelte @@ -0,0 +1,18 @@ + + +

Hi

+ + + + + +
{result}
diff --git a/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+server.js b/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+server.js new file mode 100644 index 000000000000..007c90f0d3ed --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/routing/endpoint-next-to-page/+server.js @@ -0,0 +1,24 @@ +/** @type {import('./$types').RequestHandler} */ +export function GET() { + return new Response('GET'); +} + +/** @type {import('./$types').RequestHandler} */ +export function PUT() { + return new Response('PUT'); +} + +/** @type {import('./$types').RequestHandler} */ +export function PATCH() { + return new Response('PATCH'); +} + +/** @type {import('./$types').RequestHandler} */ +export function POST() { + return new Response('POST'); +} + +/** @type {import('./$types').RequestHandler} */ +export function DELETE() { + return new Response('DELETE'); +} diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 1c12378205a8..ed55b7ae27e7 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -8,7 +8,7 @@ test.skip(({ javaScriptEnabled }) => !javaScriptEnabled); test.describe.configure({ mode: 'parallel' }); test.describe('beforeNavigate', () => { - test('prevents navigation triggered by link click', async ({ clicknav, page, baseURL }) => { + test('prevents navigation triggered by link click', async ({ page, baseURL }) => { await page.goto('/before-navigate/prevent-navigation'); await page.click('[href="/before-navigate/a"]'); @@ -785,3 +785,23 @@ test.describe('data-sveltekit attributes', () => { expect(await page.evaluate(() => window.scrollY)).toBe(0); }); }); + +test('+server.js next to +page.svelte works', async ({ page }) => { + await page.goto('/routing/endpoint-next-to-page'); + expect(await page.textContent('p')).toBe('Hi'); + + await page.click('button:has-text("GET")'); + await expect(page.locator('pre')).toHaveText('GET'); + + await page.click('button:has-text("PUT")'); + await expect(page.locator('pre')).toHaveText('PUT'); + + await page.click('button:has-text("PATCH")'); + await expect(page.locator('pre')).toHaveText('PATCH'); + + await page.click('button:has-text("POST")'); + await expect(page.locator('pre')).toHaveText('POST'); + + await page.click('button:has-text("DELETE")'); + await expect(page.locator('pre')).toHaveText('DELETE'); +}); From 285f10b24381a3b34dd7fccb9875646c925798da Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Wed, 14 Sep 2022 18:57:57 +0200 Subject: [PATCH 2/8] Update packages/kit/src/runtime/server/endpoint.js Co-authored-by: Rich Harris --- packages/kit/src/runtime/server/endpoint.js | 32 +++++++++++---------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 964ce834b59b..57189abca8e4 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -70,19 +70,21 @@ export async function render_endpoint(event, mod, state) { * @param {import('types').RequestEvent} event */ export function is_endpoint_request(event) { - const result = - // These only exist for +server - ['PUT', 'PATCH', 'DELETE'].includes(event.request.method) || - // GET has accept text/html for pages - (event.request.method === 'GET' && - negotiate(event.request.headers.get('accept') ?? '*/*', ['application/json', 'text/html']) === - 'application/json') || - // POST with FormData is for actions - (event.request.method === 'POST' && - !(event.request.headers.get('content-type') ?? '') - .split(';') - .some((part) => - ['multipart/form-data', 'application/x-www-form-urlencoded'].includes(part.trim()) - )); - return result; + const { method } = event.request; + + if (method === 'PUT' || method === 'PATCH' || method === 'DELETE') { + return true; + } + + if (method === 'GET') { + const accept = event.request.headers.get('accept') ?? '*/*'; + return negotiate(accept, ['application/json', 'text/html']) === 'application/json'; + } + + if (method === 'POST') { + const type = (event.request.headers.get('content-type') ?? '').split(';')[0]; + return type !== 'multipart/form-data' && type !== 'application/x-www-form-urlencoded'; + } + + return false; } From b2afb03386da3cf955e1f442150870af431a12ab Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 15 Sep 2022 09:48:08 +0200 Subject: [PATCH 3/8] simplify rules and document them --- documentation/docs/03-routing.md | 4 ++++ packages/kit/src/runtime/server/endpoint.js | 15 ++++----------- 2 files changed, 8 insertions(+), 11 deletions(-) diff --git a/documentation/docs/03-routing.md b/documentation/docs/03-routing.md index 7c17d59a01e9..ee31af6297b5 100644 --- a/documentation/docs/03-routing.md +++ b/documentation/docs/03-routing.md @@ -267,6 +267,10 @@ The first argument to `Response` can be a [`ReadableStream`](https://developer.m You can use the `error`, `redirect` and `json` methods from `@sveltejs/kit` for convenience (but you don't have to). Note that `throw error(..)` only returns a plain text error response. +`+server.js` files can be placed next to `+page` files. This creates an overlap since for example a `GET` to `/login` could mean both a page request or a `GET` request to `+server.js`. To distinguish, the following rules apply in this situation: +- `PUT`/`PATCH`/`DELETE` always go to `+server.js` since the methods are only applicable there +- `GET`/`POST` go to `+page.server.js` if the `accept` header is `text/html` (in other words, it's a browser page request), else they go to `+server.js` + ### $types Throughout the examples above, we've been importing types from a `$types.d.ts` file. This is a file SvelteKit creates for you in a hidden directory if you're using TypeScript (or JavaScript with JSDoc type annotations) to give you type safety when working with your root files. diff --git a/packages/kit/src/runtime/server/endpoint.js b/packages/kit/src/runtime/server/endpoint.js index 57189abca8e4..f80ed441799f 100644 --- a/packages/kit/src/runtime/server/endpoint.js +++ b/packages/kit/src/runtime/server/endpoint.js @@ -73,18 +73,11 @@ export function is_endpoint_request(event) { const { method } = event.request; if (method === 'PUT' || method === 'PATCH' || method === 'DELETE') { + // These methods exist exclusively for endpoints return true; } - if (method === 'GET') { - const accept = event.request.headers.get('accept') ?? '*/*'; - return negotiate(accept, ['application/json', 'text/html']) === 'application/json'; - } - - if (method === 'POST') { - const type = (event.request.headers.get('content-type') ?? '').split(';')[0]; - return type !== 'multipart/form-data' && type !== 'application/x-www-form-urlencoded'; - } - - return false; + // GET/POST requests may be for endpoints or pages. We prefer endpoints if this isn't a text/html request + const accept = event.request.headers.get('accept') ?? '*/*'; + return negotiate(accept, ['*', 'text/html']) !== 'text/html'; } From 4ec480bf670924d9806f9290074c56b760ac50cc Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 15 Sep 2022 09:58:05 +0200 Subject: [PATCH 4/8] adjust builder type --- packages/kit/src/core/adapt/builder.js | 2 +- packages/kit/types/private.d.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index b79fb68d7a93..18ef6ef82ea4 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -46,7 +46,7 @@ export function create_builder({ config, build_data, routes, prerendered, log }) return { id: route.id, - type: route.page ? 'page' : 'endpoint', // TODO change this if support pages+endpoints + type: route.page && route.endpoint ? 'both' : route.page ? 'page' : 'endpoint', segments: route.id.split('/').map((segment) => ({ dynamic: segment.includes('['), rest: segment.includes('[...'), diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts index 24b587ddc5e3..0cbfa730855c 100644 --- a/packages/kit/types/private.d.ts +++ b/packages/kit/types/private.d.ts @@ -195,7 +195,7 @@ export interface RequestOptions { export interface RouteDefinition { id: string; - type: 'page' | 'endpoint'; + type: 'page' | 'endpoint' | 'both'; pattern: RegExp; segments: RouteSegment[]; methods: HttpMethod[]; From 00d37d98137cb645a6f158a643e0ca214b4e3629 Mon Sep 17 00:00:00 2001 From: Simon H <5968653+dummdidumm@users.noreply.github.com> Date: Thu, 15 Sep 2022 09:58:27 +0200 Subject: [PATCH 5/8] Update documentation/docs/06-form-actions.md Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com> --- documentation/docs/06-form-actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/06-form-actions.md b/documentation/docs/06-form-actions.md index 64d1da90e779..04ff818c7b91 100644 --- a/documentation/docs/06-form-actions.md +++ b/documentation/docs/06-form-actions.md @@ -352,7 +352,7 @@ We can also implement progressive enhancement ourselves, without `use:enhance`, ### Alternatives -Form actions are specifically designed to also work without JavaScript, which means you need to use forms for it. If you don't need the page to work without JavaScript and/or you for example want to interact with an API through JSON, you can instead use `+server.js` files: +Form actions are specifically designed to also work without JavaScript, which means you need to use forms with them. If you don't need the page to work without JavaScript, you can instead use `+server.js` files, which also allows you to interact with an API through JSON for example: ```svelte /// file: src/routes/crud/+page.svelte From 06d14b649aa373eec0b0feb4ac71702cf8e5b122 Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Thu, 15 Sep 2022 10:05:59 +0200 Subject: [PATCH 6/8] link --- documentation/docs/06-form-actions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/docs/06-form-actions.md b/documentation/docs/06-form-actions.md index 04ff818c7b91..76b6f9e795e6 100644 --- a/documentation/docs/06-form-actions.md +++ b/documentation/docs/06-form-actions.md @@ -352,7 +352,7 @@ We can also implement progressive enhancement ourselves, without `use:enhance`, ### Alternatives -Form actions are specifically designed to also work without JavaScript, which means you need to use forms with them. If you don't need the page to work without JavaScript, you can instead use `+server.js` files, which also allows you to interact with an API through JSON for example: +Form actions are specifically designed to also work without JavaScript, which means you need to use forms with them. If you don't need the page to work without JavaScript, you can instead use [`+server.js`](/docs/routing#server) files, which also allows you to interact with an API through JSON for example: ```svelte /// file: src/routes/crud/+page.svelte From c59a27e86435af676504bcecc822ca1315e4dbdf Mon Sep 17 00:00:00 2001 From: Simon Holthausen Date: Mon, 19 Sep 2022 17:08:17 +0200 Subject: [PATCH 7/8] remove unused type --- packages/kit/src/core/adapt/builder.js | 1 - packages/kit/types/private.d.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index 18ef6ef82ea4..f8dcc36d1c61 100644 --- a/packages/kit/src/core/adapt/builder.js +++ b/packages/kit/src/core/adapt/builder.js @@ -46,7 +46,6 @@ export function create_builder({ config, build_data, routes, prerendered, log }) return { id: route.id, - type: route.page && route.endpoint ? 'both' : route.page ? 'page' : 'endpoint', segments: route.id.split('/').map((segment) => ({ dynamic: segment.includes('['), rest: segment.includes('[...'), diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts index 0cbfa730855c..18a2c10918f9 100644 --- a/packages/kit/types/private.d.ts +++ b/packages/kit/types/private.d.ts @@ -195,7 +195,6 @@ export interface RequestOptions { export interface RouteDefinition { id: string; - type: 'page' | 'endpoint' | 'both'; pattern: RegExp; segments: RouteSegment[]; methods: HttpMethod[]; From c29b5464fad32d05c56e4dddc0d82c0c45c09954 Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Mon, 19 Sep 2022 12:33:53 -0400 Subject: [PATCH 8/8] move docs to +server.js section --- documentation/docs/03-routing.md | 53 +++++++++++++++++++++++++-- documentation/docs/06-form-actions.md | 32 +--------------- 2 files changed, 51 insertions(+), 34 deletions(-) diff --git a/documentation/docs/03-routing.md b/documentation/docs/03-routing.md index e9913f739f8f..3ca29c382361 100644 --- a/documentation/docs/03-routing.md +++ b/documentation/docs/03-routing.md @@ -267,9 +267,56 @@ The first argument to `Response` can be a [`ReadableStream`](https://developer.m You can use the `error`, `redirect` and `json` methods from `@sveltejs/kit` for convenience (but you don't have to). Note that `throw error(..)` only returns a plain text error response. -`+server.js` files can be placed next to `+page` files. This creates an overlap since for example a `GET` to `/login` could mean both a page request or a `GET` request to `+server.js`. To distinguish, the following rules apply in this situation: -- `PUT`/`PATCH`/`DELETE` always go to `+server.js` since the methods are only applicable there -- `GET`/`POST` go to `+page.server.js` if the `accept` header is `text/html` (in other words, it's a browser page request), else they go to `+server.js` +#### Receiving data + +By exporting `POST`/`PUT`/`PATCH`/`DELETE` handlers, `+server.js` files can be used to create a complete API: + +```svelte +/// file: src/routes/add/+page.svelte + + + + + = +{total} + + +``` + +```js +/// file: src/routes/api/add/+server.js +import { json } from '@sveltejs/kit'; + +/** @type {import('./$types').RequestHandler} */ +export async function POST({ request }) { + const { a, b } = await request.json(); + return json(a + b); +} +``` + +> In general, [form actions](/docs/form-actions) are a better way to submit data from the browser to the server. + +#### Content negotiation + +`+server.js` files can be placed in the same directory as `+page` files, allowing the same route to be either a page or an API endpoint. To determine which, SvelteKit applies the following rules: + +- `PUT`/`PATCH`/`DELETE` requests are always handled by `+server.js` since they do not apply to pages +- `GET`/`POST` requests are treated as page requests if the `accept` header prioritises `text/html` (in other words, it's a browser page request), else they are handled by `+server.js` ### $types diff --git a/documentation/docs/06-form-actions.md b/documentation/docs/06-form-actions.md index bedb7b2193b4..d5fe40ad9b9c 100644 --- a/documentation/docs/06-form-actions.md +++ b/documentation/docs/06-form-actions.md @@ -354,34 +354,4 @@ We can also implement progressive enhancement ourselves, without `use:enhance`, ### Alternatives -Form actions are specifically designed to also work without JavaScript, which means you need to use forms with them. If you don't need the page to work without JavaScript, you can instead use [`+server.js`](/docs/routing#server) files, which also allows you to interact with an API through JSON for example: - -```svelte -/// file: src/routes/crud/+page.svelte - - - -

Result: {JSON.stringify(result)}

-``` - -```js -/// file: src/routes/crud/+server.js -import { json } from '@sveltejs/kit'; - -/** @type {import('./$types').RequestHandler} */ -export function PUT() { - // ... - return json({ new: 'value' }); -} -``` +Form actions are the preferred way to send data to the server, since they can be progressively enhanced, but you can also use [`+server.js`](/docs/routing#server) files to expose (for example) a JSON API. \ No newline at end of file