Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[feat] allow +server.js files next to +page files #6773

Merged
merged 10 commits into from
Sep 19, 2022
5 changes: 5 additions & 0 deletions .changeset/lazy-mice-remain.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': patch
---

[feat] allow +server.js files next to +page files
51 changes: 51 additions & 0 deletions documentation/docs/03-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<script>
let a = 0;
let b = 0;
let total = 0;

async function add() {
const response = await fetch('/api/add', {
method: 'POST',
body: JSON.stringify({ a, b }),
headers: {
'content-type': 'application/json'
}
});

total = await response.json();
}
</script>

<input type="number" bind:value={a}> +
<input type="number" bind:value={b}> =
{total}

<button on:click={add}>Calculate</button>
```

```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.
Expand Down
4 changes: 4 additions & 0 deletions documentation/docs/06-form-actions.md
Original file line number Diff line number Diff line change
Expand Up @@ -351,3 +351,7 @@ We can also implement progressive enhancement ourselves, without `use:enhance`,
<!-- content -->
</form>
```

### 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.
1 change: 0 additions & 1 deletion packages/kit/src/core/adapt/builder.js
Original file line number Diff line number Diff line change
Expand Up @@ -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('[...'),
Expand Down
5 changes: 0 additions & 5 deletions packages/kit/src/core/sync/create_manifest_data/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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: [],
Expand Down
17 changes: 17 additions & 0 deletions packages/kit/src/runtime/server/endpoint.js
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -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';
}
6 changes: 3 additions & 3 deletions packages/kit/src/runtime/server/index.js
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<script>
let result;

/** @param {string} method */
async function request(method) {
result = 'loading';
const response = await fetch('/routing/endpoint-next-to-page', {method});
result = await response.text();
}
</script>

<p>Hi</p>
<button on:click={() => request('GET')}>GET</button>
<button on:click={() => request('PUT')}>PUT</button>
<button on:click={() => request('PATCH')}>PATCH</button>
<button on:click={() => request('POST')}>POST</button>
<button on:click={() => request('DELETE')}>DELETE</button>
<pre>{result}</pre>
Original file line number Diff line number Diff line change
@@ -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');
}
22 changes: 21 additions & 1 deletion packages/kit/test/apps/basics/test/client.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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"]');
Expand Down Expand Up @@ -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');
});
1 change: 0 additions & 1 deletion packages/kit/types/private.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,6 @@ export interface RequestOptions {

export interface RouteDefinition {
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

aside: it's strange to me that this is private API - it's public because it's part of the public builder methods

id: string;
type: 'page' | 'endpoint';
pattern: RegExp;
segments: RouteSegment[];
methods: HttpMethod[];
Expand Down