Skip to content

Commit

Permalink
[feat] allow +server.js files next to +page files (#6773)
Browse files Browse the repository at this point in the history
* [feat] allow +server.js files next to +page files

Closes #5896

* Update packages/kit/src/runtime/server/endpoint.js

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>

* simplify rules and document them

* adjust builder type

* Update documentation/docs/06-form-actions.md

Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>

* link

* remove unused type

* move docs to +server.js section

Co-authored-by: Rich Harris <richard.a.harris@gmail.com>
Co-authored-by: Ben McCann <322311+benmccann@users.noreply.github.com>
Co-authored-by: Rich Harris <hello@rich-harris.dev>
  • Loading branch information
4 people authored Sep 19, 2022
1 parent 6234c07 commit 9ef548a
Show file tree
Hide file tree
Showing 11 changed files with 143 additions and 11 deletions.
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 {
id: string;
type: 'page' | 'endpoint';
pattern: RegExp;
segments: RouteSegment[];
methods: HttpMethod[];
Expand Down

2 comments on commit 9ef548a

@yousufiqbal
Copy link
Contributor

Choose a reason for hiding this comment

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

+actions.server.js was shifted to +page.server.js. Don't you think same should be done to +server.js?

@yousufiqbal
Copy link
Contributor

Choose a reason for hiding this comment

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

Current commit is also awesome for my workflow. Above comment is just an idea .

Please sign in to comment.