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/03-routing.md b/documentation/docs/03-routing.md index ac5598dd72a2..3ca29c382361 100644 --- a/documentation/docs/03-routing.md +++ b/documentation/docs/03-routing.md @@ -267,6 +267,57 @@ 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. +#### 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 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/documentation/docs/06-form-actions.md b/documentation/docs/06-form-actions.md index fcf1300a835b..d5fe40ad9b9c 100644 --- a/documentation/docs/06-form-actions.md +++ b/documentation/docs/06-form-actions.md @@ -351,3 +351,7 @@ We can also implement progressive enhancement ourselves, without `use:enhance`, ``` + +### Alternatives + +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 diff --git a/packages/kit/src/core/adapt/builder.js b/packages/kit/src/core/adapt/builder.js index b79fb68d7a93..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 ? 'page' : 'endpoint', // TODO change this if support pages+endpoints segments: route.id.split('/').map((segment) => ({ dynamic: segment.includes('['), rest: segment.includes('[...'), 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..f80ed441799f 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,19 @@ export async function render_endpoint(event, mod, state) { throw error; } } + +/** + * @param {import('types').RequestEvent} event + */ +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; + } + + // 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'; +} diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index cff90f3c0b2a..9070608a00f6 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,4 +1,4 @@ -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'; @@ -226,10 +226,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 cf12fdc98ba8..42dedf5b7b45 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"]'); @@ -807,3 +807,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'); +}); diff --git a/packages/kit/types/private.d.ts b/packages/kit/types/private.d.ts index 24b587ddc5e3..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'; pattern: RegExp; segments: RouteSegment[]; methods: HttpMethod[];