Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/moody-ties-impress.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: add server endpoint catch-all method handler `fallback`
25 changes: 25 additions & 0 deletions documentation/docs/20-core-concepts/10-routing.md
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,31 @@ export async function POST({ request }) {

> In general, [form actions](form-actions) are a better way to submit data from the browser to the server.

> If a `GET` handler is exported, a `HEAD` request will return the `content-length` of the `GET` handler's response body.

### Fallback method handler

Exporting the `fallback` handler will match any unhandled request methods, including methods like `MOVE` which have no dedicated export from `+server.js`.

```js
// @errors: 7031
/// file: src/routes/api/add/+server.js
import { json, text } from '@sveltejs/kit';

export async function POST({ request }) {
const { a, b } = await request.json();
return json(a + b);
}

// This handler will respond to PUT, PATCH, DELETE, etc.
/** @type {import('./$types').RequestHandler} */
export async function fallback({ request }) {
return text(`I caught your ${request.method} request!`);
}
```

> For `HEAD` requests, the `GET` handler takes precedence over the `fallback` handler.

### 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:
Expand Down
4 changes: 3 additions & 1 deletion packages/kit/src/core/postbuild/analyse.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ async function analyse({ manifest_path, env }) {
/** @type {Array<'GET' | 'POST'>} */
const page_methods = [];

/** @type {import('types').HttpMethod[]} */
/** @type {(import('types').HttpMethod | '*')[]} */
const api_methods = [];

/** @type {import('types').PrerenderOption | undefined} */
Expand All @@ -96,6 +96,8 @@ async function analyse({ manifest_path, env }) {
Object.values(mod).forEach((/** @type {import('types').HttpMethod} */ method) => {
if (mod[method] && ENDPOINT_METHODS.has(method)) {
api_methods.push(method);
} else if (mod.fallback) {
api_methods.push('*');
}
});

Expand Down
10 changes: 5 additions & 5 deletions packages/kit/src/exports/public.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,7 @@ export interface LoadEvent<
*
* Setting the same header multiple times (even in separate `load` functions) is an error — you can only set a given header once.
*
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://kit.svelte.dev/docs/types#public-types-cookies) API in a server-only `load` function instead.
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://kit.svelte.dev/docs/types#public-types-cookies) API in a server-only `load` function instead.
*
* `setHeaders` has no effect when a `load` function runs in the browser.
*/
Expand Down Expand Up @@ -1005,7 +1005,7 @@ export interface RequestEvent<
*
* Setting the same header multiple times (even in separate `load` functions) is an error — you can only set a given header once.
*
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://kit.svelte.dev/docs/types#public-types-cookies) API instead.
* You cannot add a `set-cookie` header with `setHeaders` — use the [`cookies`](https://kit.svelte.dev/docs/types#public-types-cookies) API instead.
*/
setHeaders(headers: Record<string, string>): void;
/**
Expand Down Expand Up @@ -1059,15 +1059,15 @@ export interface ResolveOptions {
export interface RouteDefinition<Config = any> {
id: string;
api: {
methods: HttpMethod[];
methods: Array<HttpMethod | '*'>;
};
page: {
methods: Extract<HttpMethod, 'GET' | 'POST'>[];
methods: Array<Extract<HttpMethod, 'GET' | 'POST'>>;
};
pattern: RegExp;
prerender: PrerenderOption;
segments: RouteSegment[];
methods: HttpMethod[];
methods: Array<HttpMethod | '*'>;
config: Config;
}

Expand Down
4 changes: 2 additions & 2 deletions packages/kit/src/runtime/server/endpoint.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ import { method_not_allowed } from './utils.js';
export async function render_endpoint(event, mod, state) {
const method = /** @type {import('types').HttpMethod} */ (event.request.method);

let handler = mod[method];
let handler = mod[method] || mod.fallback;

if (!handler && method === 'HEAD') {
if (method === 'HEAD' && mod.GET && !mod.HEAD) {
handler = mod.GET;
}

Expand Down
7 changes: 4 additions & 3 deletions packages/kit/src/types/internal.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -259,14 +259,14 @@ export interface ServerErrorNode {
export interface ServerMetadataRoute {
config: any;
api: {
methods: HttpMethod[];
methods: Array<HttpMethod | '*'>;
};
page: {
methods: Array<'GET' | 'POST'>;
};
methods: HttpMethod[];
methods: Array<HttpMethod | '*'>;
prerender: PrerenderOption | undefined;
entries: Array<string> | undefined;
entries: string[] | undefined;
}

export interface ServerMetadata {
Expand Down Expand Up @@ -367,6 +367,7 @@ export type SSREndpoint = Partial<Record<HttpMethod, RequestHandler>> & {
trailingSlash?: TrailingSlash;
config?: any;
entries?: PrerenderEntryGenerator;
fallback?: RequestHandler;
};

export interface SSRRoute {
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/utils/exports.js
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ const valid_server_exports = new Set([
'DELETE',
'OPTIONS',
'HEAD',
'fallback',
'prerender',
'trailingSlash',
'config',
Expand Down
2 changes: 1 addition & 1 deletion packages/kit/src/utils/exports.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -174,7 +174,7 @@ test('validates +server.js', () => {
validate_server_exports({
answer: 42
});
}, "Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD, prerender, trailingSlash, config, entries, or anything with a '_' prefix)");
}, "Invalid export 'answer' (valid exports are GET, POST, PATCH, PUT, DELETE, OPTIONS, HEAD, fallback, prerender, trailingSlash, config, entries, or anything with a '_' prefix)");

check_error(() => {
validate_server_exports({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
export function GET() {
return new Response('ok');
}

export function fallback() {
return new Response('catch-all');
}
25 changes: 25 additions & 0 deletions packages/kit/test/apps/basics/test/server.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,31 @@ test.describe('Endpoints', () => {
expect(await endpoint_response.text()).toBe('');
expect(endpoint_response.headers()['x-sveltekit-head-endpoint']).toBe('true');
});

test('catch-all handler', async ({ request }) => {
const url = '/endpoint-output/fallback';

let response = await request.fetch(url, {
method: 'GET'
});

expect(response.status()).toBe(200);
expect(await response.text()).toBe('ok');

response = await request.fetch(url, {
method: 'MOVE' // also works with arcane methods
});

expect(response.status()).toBe(200);
expect(await response.text()).toBe('catch-all');

response = await request.fetch(url, {
method: 'OPTIONS'
});

expect(response.status()).toBe(200);
expect(await response.text()).toBe('catch-all');
});
});

test.describe('Errors', () => {
Expand Down