diff --git a/.changeset/chilly-moose-provide.md b/.changeset/chilly-moose-provide.md new file mode 100644 index 000000000000..e1cb7889966f --- /dev/null +++ b/.changeset/chilly-moose-provide.md @@ -0,0 +1,6 @@ +--- +'@sveltejs/kit': patch +'create-svelte': patch +--- + +Add methodOverride option for submitting PUT/PATCH/DELETE/etc with
elements diff --git a/documentation/docs/01-routing.md b/documentation/docs/01-routing.md index c74c677f4c0a..56dfd9517bd0 100644 --- a/documentation/docs/01-routing.md +++ b/documentation/docs/01-routing.md @@ -159,6 +159,29 @@ The `body` property of the request object will be provided in the case of POST r - Form data (with content-type `application/x-www-form-urlencoded` or `multipart/form-data`) will be parsed to a read-only version of the [`FormData`](https://developer.mozilla.org/en-US/docs/Web/API/FormData) object. - All other data will be provided as a `Uint8Array` +#### HTTP Method Overrides + +HTML `` elements only support `GET` and `POST` methods natively. You can allow other methods, like `PUT` and `DELETE`, by specifying them in your [configuration](#configuration-methodoverride) and adding a `_method=VERB` parameter (you can configure the name) to the form's `action`: + +```js +// svelte.config.js +export default { + kit: { + methodOverride: { + allowed: ['PUT', 'PATCH', 'DELETE'] + } + } +}; +``` + +```html + + +
+``` + +> Using native `
` behaviour ensures your app continues to work when JavaScript fails or is disabled. + ### Private modules A filename that has a segment with a leading underscore, such as `src/routes/foo/_Private.svelte` or `src/routes/bar/_utils/cool-util.js`, is hidden from the router, but can be imported by files that are not. diff --git a/documentation/docs/14-configuration.md b/documentation/docs/14-configuration.md index 4bedef276d65..1c7666a31481 100644 --- a/documentation/docs/14-configuration.md +++ b/documentation/docs/14-configuration.md @@ -32,6 +32,10 @@ const config = { }, host: null, hydrate: true, + methodOverride: { + parameter: '_method', + allowed: [] + }, package: { dir: 'package', emitTypes: true, @@ -134,6 +138,13 @@ A value that overrides the one derived from [`config.kit.headers.host`](#configu Whether to [hydrate](#ssr-and-javascript-hydrate) the server-rendered HTML with a client-side app. (It's rare that you would set this to `false` on an app-wide basis.) +### methodOverride + +See [HTTP Method Overrides](#routing-endpoints-http-method-overrides). An object containing zero or more of the following: + +- `parameter` — query parameter name to use for passing the intended method value +- `allowed` - array of HTTP methods that can be used when overriding the original request method + ### package Options related to [creating a package](#packaging). diff --git a/packages/create-svelte/templates/default/src/hooks.ts b/packages/create-svelte/templates/default/src/hooks.ts index 622443f5030d..ce345fed108f 100644 --- a/packages/create-svelte/templates/default/src/hooks.ts +++ b/packages/create-svelte/templates/default/src/hooks.ts @@ -6,12 +6,6 @@ export const handle: Handle = async ({ request, resolve }) => { const cookies = cookie.parse(request.headers.cookie || ''); request.locals.userid = cookies.userid || uuid(); - // TODO https://github.com/sveltejs/kit/issues/1046 - const method = request.url.searchParams.get('_method'); - if (method) { - request.method = method.toUpperCase(); - } - const response = await resolve(request); if (!cookies.userid) { diff --git a/packages/create-svelte/templates/default/src/routes/todos/index.svelte b/packages/create-svelte/templates/default/src/routes/todos/index.svelte index f4db69b1c1e8..15048569bfc7 100644 --- a/packages/create-svelte/templates/default/src/routes/todos/index.svelte +++ b/packages/create-svelte/templates/default/src/routes/todos/index.svelte @@ -77,7 +77,7 @@ animate:flip={{ duration: 200 }} > { @@ -92,7 +92,7 @@ (todo.pending_delete = true), diff --git a/packages/create-svelte/templates/default/svelte.config.js b/packages/create-svelte/templates/default/svelte.config.js index aa85d10b3262..3921efa5f072 100644 --- a/packages/create-svelte/templates/default/svelte.config.js +++ b/packages/create-svelte/templates/default/svelte.config.js @@ -11,7 +11,12 @@ const config = { adapter: adapter(), // hydrate the
element in src/app.html - target: '#svelte' + target: '#svelte', + + // Override http methods in the Todo forms + methodOverride: { + allowed: ['PATCH', 'DELETE'] + } } }; diff --git a/packages/kit/src/core/build/build_server.js b/packages/kit/src/core/build/build_server.js index af5f9bbd1ff2..ef3ec65d91a8 100644 --- a/packages/kit/src/core/build/build_server.js +++ b/packages/kit/src/core/build/build_server.js @@ -71,6 +71,7 @@ export class App { hooks, hydrate: ${s(config.kit.hydrate)}, manifest, + method_override: ${s(config.kit.methodOverride)}, paths: { base, assets }, prefix: assets + '/${config.kit.appDir}/', prerender: ${config.kit.prerender.enabled}, diff --git a/packages/kit/src/core/config/index.spec.js b/packages/kit/src/core/config/index.spec.js index 3c508d6885c4..f127150333b4 100644 --- a/packages/kit/src/core/config/index.spec.js +++ b/packages/kit/src/core/config/index.spec.js @@ -35,6 +35,10 @@ test('fills in defaults', () => { }, host: null, hydrate: true, + methodOverride: { + parameter: '_method', + allowed: [] + }, package: { dir: 'package', emitTypes: true @@ -142,6 +146,10 @@ test('fills in partial blanks', () => { }, host: null, hydrate: true, + methodOverride: { + parameter: '_method', + allowed: [] + }, package: { dir: 'package', emitTypes: true diff --git a/packages/kit/src/core/config/options.js b/packages/kit/src/core/config/options.js index 23837a0a4c2a..2082ae5f056a 100644 --- a/packages/kit/src/core/config/options.js +++ b/packages/kit/src/core/config/options.js @@ -75,6 +75,21 @@ const options = object( hydrate: boolean(true), + methodOverride: object({ + parameter: string('_method'), + allowed: validate([], (input, keypath) => { + if (!Array.isArray(input) || !input.every((method) => typeof method === 'string')) { + throw new Error(`${keypath} must be an array of strings`); + } + + if (input.map((i) => i.toUpperCase()).includes('GET')) { + throw new Error(`${keypath} cannot contain "GET"`); + } + + return input; + }) + }), + package: object({ dir: string('package'), // excludes all .d.ts and filename starting with _ diff --git a/packages/kit/src/core/config/test/index.js b/packages/kit/src/core/config/test/index.js index 52c0ac270495..4308bf15eb92 100644 --- a/packages/kit/src/core/config/test/index.js +++ b/packages/kit/src/core/config/test/index.js @@ -37,6 +37,10 @@ test('load default config (esm)', async () => { }, host: null, hydrate: true, + methodOverride: { + parameter: '_method', + allowed: [] + }, package: { dir: 'package', emitTypes: true diff --git a/packages/kit/src/core/dev/plugin.js b/packages/kit/src/core/dev/plugin.js index b12a4d237186..bd8f7cc1afbb 100644 --- a/packages/kit/src/core/dev/plugin.js +++ b/packages/kit/src/core/dev/plugin.js @@ -214,6 +214,7 @@ export async function create_plugin(config, output, cwd) { hooks, hydrate: config.kit.hydrate, manifest, + method_override: config.kit.methodOverride, paths: { base: config.kit.paths.base, assets: config.kit.paths.assets ? SVELTE_KIT_ASSETS : config.kit.paths.base diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index cd7fa3febee8..62733e1140eb 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -43,6 +43,28 @@ export async function respond(incoming, options, state = {}) { locals: {} }; + const { parameter, allowed } = options.method_override; + const method_override = incoming.url.searchParams.get(parameter)?.toUpperCase(); + + if (method_override) { + if (request.method.toUpperCase() === 'POST') { + if (allowed.includes(method_override)) { + request.method = method_override; + } else { + const verb = allowed.length === 0 ? 'enabled' : 'allowed'; + const body = `${parameter}=${method_override} is not ${verb}. See https://kit.svelte.dev/docs#configuration-methodoverride`; + + return { + status: 400, + headers: {}, + body + }; + } + } else { + throw new Error(`${parameter}=${method_override} is only allowed with POST requests`); + } + } + // TODO remove this for 1.0 /** * @param {string} property diff --git a/packages/kit/src/runtime/server/parse_body/read_only_form_data.js b/packages/kit/src/runtime/server/parse_body/read_only_form_data.js index 9f4aaeb965ac..4978ef455323 100644 --- a/packages/kit/src/runtime/server/parse_body/read_only_form_data.js +++ b/packages/kit/src/runtime/server/parse_body/read_only_form_data.js @@ -19,7 +19,7 @@ export function read_only_form_data() { }; } -class ReadOnlyFormData { +export class ReadOnlyFormData { /** @type {Map} */ #map; diff --git a/packages/kit/test/apps/basics/src/routes/method-override/fetch.json.js b/packages/kit/test/apps/basics/src/routes/method-override/fetch.json.js new file mode 100644 index 000000000000..f33471c06b09 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/method-override/fetch.json.js @@ -0,0 +1,26 @@ +const buildResponse = (/** @type {string} */ method) => ({ + status: 303, + headers: { + location: `/method-override?method=${method}` + } +}); + +/** @type {import('@sveltejs/kit').RequestHandler} */ +export const get = (request) => { + return buildResponse(request.method); +}; + +/** @type {import('@sveltejs/kit').RequestHandler} */ +export const post = (request) => { + return buildResponse(request.method); +}; + +/** @type {import('@sveltejs/kit').RequestHandler} */ +export const patch = (request) => { + return buildResponse(request.method); +}; + +/** @type {import('@sveltejs/kit').RequestHandler} */ +export const del = (request) => { + return buildResponse(request.method); +}; diff --git a/packages/kit/test/apps/basics/src/routes/method-override/index.svelte b/packages/kit/test/apps/basics/src/routes/method-override/index.svelte new file mode 100644 index 000000000000..f88ae1417ef9 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/method-override/index.svelte @@ -0,0 +1,42 @@ + + + + +

{method}

+ + + + + + +
+ + +
+ +
+ + +
+ +
+ + +
+ +
+ + +
diff --git a/packages/kit/test/apps/basics/svelte.config.js b/packages/kit/test/apps/basics/svelte.config.js index e5e3f5df2ed2..d5b673464b77 100644 --- a/packages/kit/test/apps/basics/svelte.config.js +++ b/packages/kit/test/apps/basics/svelte.config.js @@ -11,6 +11,9 @@ const config = { // the reload confuses Playwright include: ['cookie', 'marked'] } + }, + methodOverride: { + allowed: ['PUT', 'PATCH', 'DELETE'] } } }; diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index 07e1944546e0..93f9854c49cb 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -923,6 +923,52 @@ test.describe.parallel('Load', () => { }); }); +test.describe.parallel('Method overrides', () => { + test('http method is overridden via URL parameter', async ({ page }) => { + await page.goto('/method-override'); + + let val; + + // Check initial value + val = await page.textContent('h1'); + expect('').toBe(val); + + await page.click('"PATCH"'); + val = await page.textContent('h1'); + expect('PATCH').toBe(val); + + await page.click('"DELETE"'); + val = await page.textContent('h1'); + expect('DELETE').toBe(val); + }); + + test('GET method is not overridden', async ({ page }) => { + await page.goto('/method-override'); + await page.click('"No Override From GET"'); + + const val = await page.textContent('h1'); + expect('GET').toBe(val); + }); + + test('400 response when trying to override POST with GET', async ({ page }) => { + await page.goto('/method-override'); + await page.click('"No Override To GET"'); + + expect(await page.innerHTML('pre')).toBe( + '_method=GET is not allowed. See https://kit.svelte.dev/docs#configuration-methodoverride' + ); + }); + + test('400 response when override method not in allowed methods', async ({ page }) => { + await page.goto('/method-override'); + await page.click('"No Override To CONNECT"'); + + expect(await page.innerHTML('pre')).toBe( + '_method=CONNECT is not allowed. See https://kit.svelte.dev/docs#configuration-methodoverride' + ); + }); +}); + test.describe.parallel('Nested layouts', () => { test('renders a nested layout', async ({ page }) => { await page.goto('/nested-layout'); diff --git a/packages/kit/types/config.d.ts b/packages/kit/types/config.d.ts index e453335bb298..e77eefdc0b29 100644 --- a/packages/kit/types/config.d.ts +++ b/packages/kit/types/config.d.ts @@ -131,6 +131,10 @@ export interface Config { }; host?: string; hydrate?: boolean; + methodOverride?: { + parameter?: string; + allowed?: string[]; + }; package?: { dir?: string; emitTypes?: boolean; diff --git a/packages/kit/types/internal.d.ts b/packages/kit/types/internal.d.ts index 61244dac4c2a..fa7fa1f53b57 100644 --- a/packages/kit/types/internal.d.ts +++ b/packages/kit/types/internal.d.ts @@ -131,6 +131,7 @@ export interface SSRRenderOptions { hooks: Hooks; hydrate: boolean; manifest: SSRManifest; + method_override: MethodOverride; paths: { base: string; assets: string; @@ -230,3 +231,7 @@ export type NormalizedLoadOutput = Either< >; export type TrailingSlash = 'never' | 'always' | 'ignore'; +export interface MethodOverride { + parameter: string; + allowed: string[]; +}