diff --git a/.changeset/clever-lizards-grab.md b/.changeset/clever-lizards-grab.md new file mode 100644 index 000000000000..906cd0bdffab --- /dev/null +++ b/.changeset/clever-lizards-grab.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-node': patch +--- + +Use getRawBody diff --git a/.changeset/cold-buttons-sell.md b/.changeset/cold-buttons-sell.md new file mode 100644 index 000000000000..d234d85ed087 --- /dev/null +++ b/.changeset/cold-buttons-sell.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-cloudflare-workers': patch +--- + +Pass rawBody to SvelteKit, bundle worker with esbuild diff --git a/.changeset/flat-ducks-impress.md b/.changeset/flat-ducks-impress.md new file mode 100644 index 000000000000..48eb6909ad2c --- /dev/null +++ b/.changeset/flat-ducks-impress.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': patch +--- + +Fix dependencies diff --git a/.changeset/lemon-ways-doubt.md b/.changeset/lemon-ways-doubt.md new file mode 100644 index 000000000000..f3283be10436 --- /dev/null +++ b/.changeset/lemon-ways-doubt.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-netlify': patch +--- + +Fix dependencies diff --git a/.changeset/modern-dryers-join.md b/.changeset/modern-dryers-join.md new file mode 100644 index 000000000000..97d4df2b7e89 --- /dev/null +++ b/.changeset/modern-dryers-join.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Expose rawBody on request, and expect rawBody from adapters diff --git a/.changeset/nasty-boats-bathe.md b/.changeset/nasty-boats-bathe.md new file mode 100644 index 000000000000..a4fc75762aac --- /dev/null +++ b/.changeset/nasty-boats-bathe.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-netlify': patch +--- + +Pass rawBody from netlify adapter diff --git a/.changeset/tiny-candles-repeat.md b/.changeset/tiny-candles-repeat.md new file mode 100644 index 000000000000..4a7f652242d9 --- /dev/null +++ b/.changeset/tiny-candles-repeat.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Expose getRawBody from kit/http diff --git a/.changeset/unlucky-wasps-rest.md b/.changeset/unlucky-wasps-rest.md new file mode 100644 index 000000000000..0ac426b23eae --- /dev/null +++ b/.changeset/unlucky-wasps-rest.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/adapter-vercel': patch +--- + +Use getRawBody in adapter-vercel diff --git a/.gitignore b/.gitignore index 3f8f49b69001..a25058664255 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,4 @@ yarn.lock .vercel_build_output .netlify .svelte +.cloudflare \ No newline at end of file diff --git a/documentation/docs/01-routing.md b/documentation/docs/01-routing.md index c052e3132014..df7a04f2d9cd 100644 --- a/documentation/docs/01-routing.md +++ b/documentation/docs/01-routing.md @@ -53,7 +53,8 @@ type Request = { path: string; params: Record; query: URLSearchParams; - body: string | Buffer | ReadOnlyFormData; + rawBody: string | ArrayBuffer; + body: string | ArrayBuffer | ReadOnlyFormData | any; context: Context; // see getContext, below }; @@ -95,10 +96,10 @@ export async function get({ params }) { The job of this function is to return a `{status, headers, body}` object representing the response, where `status` is an [HTTP status code](https://httpstatusdogs.com): -* `2xx` — successful response (default is `200`) -* `3xx` — redirection (should be accompanied by a `location` header) -* `4xx` — client error -* `5xx` — server error +- `2xx` — successful response (default is `200`) +- `3xx` — redirection (should be accompanied by a `location` header) +- `4xx` — client error +- `5xx` — server error > For successful responses, SvelteKit will generate 304s automatically @@ -126,7 +127,6 @@ return { }; ``` - ### 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/04-hooks.md b/documentation/docs/04-hooks.md index bb4f1e961372..565961b52014 100644 --- a/documentation/docs/04-hooks.md +++ b/documentation/docs/04-hooks.md @@ -85,7 +85,8 @@ type Request = { path: string; params: Record; query: URLSearchParams; - body: string | Buffer | ReadOnlyFormData; + rawBody: string | ArrayBuffer; + body: string | ArrayBuffer | ReadOnlyFormData | any; context: Context; }; diff --git a/packages/adapter-cloudflare-workers/files/render.js b/packages/adapter-cloudflare-workers/files/entry.js similarity index 50% rename from packages/adapter-cloudflare-workers/files/render.js rename to packages/adapter-cloudflare-workers/files/entry.js index a12d9f645232..e52606ff401f 100644 --- a/packages/adapter-cloudflare-workers/files/render.js +++ b/packages/adapter-cloudflare-workers/files/entry.js @@ -1,33 +1,17 @@ -import { render } from './app.js'; // eslint-disable-line import/no-unresolved +// TODO hardcoding the relative location makes this brittle +import { render } from '../output/server/app.js'; // eslint-disable-line import/no-unresolved import { getAssetFromKV, NotFoundError } from '@cloudflare/kv-asset-handler'; // eslint-disable-line import/no-unresolved -// From https://developers.cloudflare.com/workers/examples/read-post -async function readRequestBody(request) { - const { headers } = request; - const contentType = headers.get('content-type') || ''; - if (contentType.includes('application/json')) { - return await request.json(); - } else if (contentType.includes('application/text')) { - return await request.text(); - } else if (contentType.includes('text/html')) { - return await request.text(); - } else if (contentType.includes('form')) { - return await request.formData(); - } else { - const myBlob = await request.blob(); - const objectURL = URL.createObjectURL(myBlob); - return objectURL; - } -} - addEventListener('fetch', (event) => { - event.respondWith(handleEvent(event)); + event.respondWith(handle(event)); }); -async function handleEvent(event) { - //try static files first +async function handle(event) { + // try static files first if (event.request.method == 'GET') { try { + // TODO rather than attempting to get an asset, + // use the asset manifest to see if it exists return await getAssetFromKV(event); } catch (e) { if (!(e instanceof NotFoundError)) { @@ -38,7 +22,7 @@ async function handleEvent(event) { } } - //fall back to an app route + // fall back to an app route const request = event.request; const request_url = new URL(request.url); @@ -47,17 +31,16 @@ async function handleEvent(event) { host: request_url.host, path: request_url.pathname, query: request_url.searchParams, - body: request.body ? await readRequestBody(request) : null, + rawBody: request.body ? await read(request) : null, headers: Object.fromEntries(request.headers), method: request.method }); if (rendered) { - const response = new Response(rendered.body, { + return new Response(rendered.body, { status: rendered.status, headers: rendered.headers }); - return response; } } catch (e) { return new Response('Error rendering route:' + (e.message || e.toString()), { status: 500 }); @@ -68,3 +51,12 @@ async function handleEvent(event) { statusText: 'Not Found' }); } + +function read(request) { + const type = request.headers.get('content-type') || ''; + if (type.includes('application/octet-stream')) { + return request.arrayBuffer(); + } + + return request.text(); +} diff --git a/packages/adapter-cloudflare-workers/index.js b/packages/adapter-cloudflare-workers/index.js index aec565506f04..b75eb73cc40d 100644 --- a/packages/adapter-cloudflare-workers/index.js +++ b/packages/adapter-cloudflare-workers/index.js @@ -1,8 +1,8 @@ 'use strict'; -const { exec } = require('child_process'); const fs = require('fs'); -const path = require('path'); +const { execSync } = require('child_process'); +const esbuild = require('esbuild'); const toml = require('toml'); module.exports = function () { @@ -10,59 +10,86 @@ module.exports = function () { const adapter = { name: '@sveltejs/adapter-cloudflare-workers', async adapt(utils) { - let wrangler_config; - - if (fs.existsSync('wrangler.toml')) { - try { - wrangler_config = toml.parse(fs.readFileSync('wrangler.toml', 'utf-8')); - } catch (err) { - err.message = `Error parsing wrangler.toml: ${err.message}`; - throw err; - } - } else { - // TODO offer to create one? - throw new Error( - 'Missing a wrangler.toml file. Consult https://developers.cloudflare.com/workers/platform/sites/configuration on how to setup your site' - ); - } - - if (!wrangler_config.site || !wrangler_config.site.bucket) { - throw new Error( - 'You must specify site.bucket in wrangler.toml. Consult https://developers.cloudflare.com/workers/platform/sites/configuration' - ); - } - - const bucket = path.resolve(wrangler_config.site.bucket); - const entrypoint = path.resolve(wrangler_config.site['entry-point'] ?? 'workers-site'); + const { site } = validate_config(utils); - utils.copy_static_files(bucket); - utils.copy_client_files(bucket); - utils.copy_server_files(entrypoint); + const bucket = site.bucket; + const entrypoint = site['entry-point'] || 'workers-site'; + + utils.rimraf(bucket); + utils.rimraf(entrypoint); - // copy the renderer - utils.copy(path.resolve(__dirname, 'files/render.js'), `${entrypoint}/index.js`); - utils.copy(path.resolve(__dirname, 'files/_package.json'), `${entrypoint}/package.json`); + utils.log.info('Installing worker dependencies...'); + utils.copy(`${__dirname}/files/_package.json`, '.svelte/cloudflare-workers/package.json'); + + // TODO would be cool if we could make this step unnecessary somehow + const stdout = execSync('npm install', { cwd: '.svelte/cloudflare-workers' }); + utils.log.info(stdout.toString()); + + utils.log.minor('Generating worker...'); + utils.copy(`${__dirname}/files/entry.js`, '.svelte/cloudflare-workers/entry.js'); + + await esbuild.build({ + entryPoints: ['.svelte/cloudflare-workers/entry.js'], + outfile: `${entrypoint}/index.js`, + bundle: true, + platform: 'node' + }); utils.log.info('Prerendering static pages...'); await utils.prerender({ dest: bucket }); - utils.log.info('Installing Worker Dependencies...'); - exec( - 'npm install', - { - cwd: entrypoint - }, - (error, stdout, stderr) => { - utils.log.info(stderr); - if (error) { - utils.log.error(error); - } - } - ); + utils.log.minor('Copying assets...'); + utils.copy_static_files(bucket); + utils.copy_client_files(bucket); } }; return adapter; }; + +function validate_config(utils) { + if (fs.existsSync('wrangler.toml')) { + let wrangler_config; + + try { + wrangler_config = toml.parse(fs.readFileSync('wrangler.toml', 'utf-8')); + } catch (err) { + err.message = `Error parsing wrangler.toml: ${err.message}`; + throw err; + } + + if (!wrangler_config.site || !wrangler_config.site.bucket) { + throw new Error( + 'You must specify site.bucket in wrangler.toml. Consult https://developers.cloudflare.com/workers/platform/sites/configuration' + ); + } + + return wrangler_config; + } + + utils.log.error( + 'Consult https://developers.cloudflare.com/workers/platform/sites/configuration on how to setup your site' + ); + + utils.log( + ` + Sample wrangler.toml: + + name = "" + type = "javascript" + account_id = "" + workers_dev = true + route = "" + zone_id = "" + + [site] + bucket = "./.cloudflare/assets" + entry-point = "./.cloudflare/worker"` + .replace(/^\t+/gm, '') + .trim() + ); + + throw new Error('Missing a wrangler.toml file'); +} diff --git a/packages/adapter-cloudflare-workers/package.json b/packages/adapter-cloudflare-workers/package.json index 0f934e4f0c9b..9a8d73676311 100644 --- a/packages/adapter-cloudflare-workers/package.json +++ b/packages/adapter-cloudflare-workers/package.json @@ -11,6 +11,7 @@ "check-format": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore" }, "dependencies": { + "esbuild": "^0.11.12", "toml": "^3.0.0" }, "devDependencies": { diff --git a/packages/adapter-netlify/files/entry.js b/packages/adapter-netlify/files/entry.js index 09eaad2368c7..8435fb513d41 100644 --- a/packages/adapter-netlify/files/entry.js +++ b/packages/adapter-netlify/files/entry.js @@ -4,14 +4,7 @@ import '@sveltejs/kit/install-fetch'; // eslint-disable-line import/no-unresolve import { render } from '../output/server/app.js'; // eslint-disable-line import/no-unresolved export async function handler(event) { - const { - path, - httpMethod, - headers, - queryStringParameters - // body, // TODO pass this to renderer - // isBase64Encoded // TODO is this useful? - } = event; + const { path, httpMethod, headers, queryStringParameters, body, isBase64Encoded } = event; const query = new URLSearchParams(); for (const k in queryStringParameters) { @@ -25,7 +18,8 @@ export async function handler(event) { method: httpMethod, headers, path, - query + query, + rawBody: isBase64Encoded ? new TextEncoder('base64').encode(body).buffer : body }); if (rendered) { diff --git a/packages/adapter-netlify/package.json b/packages/adapter-netlify/package.json index ac0de255e442..f69bb9bfbc4f 100644 --- a/packages/adapter-netlify/package.json +++ b/packages/adapter-netlify/package.json @@ -11,10 +11,11 @@ "check-format": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore" }, "dependencies": { + "@sveltejs/kit": "workspace:*", + "esbuild": "^0.11.12", "toml": "^3.0.0" }, "devDependencies": { - "@sveltejs/kit": "workspace:*", "typescript": "^4.2.3" } } diff --git a/packages/adapter-node/src/server.js b/packages/adapter-node/src/server.js index ba43bf0c6c5c..e4eb7ca5afed 100644 --- a/packages/adapter-node/src/server.js +++ b/packages/adapter-node/src/server.js @@ -4,7 +4,7 @@ import { fileURLToPath } from 'url'; import compression from 'compression'; import polka from 'polka'; import sirv from 'sirv'; -import { get_body } from '@sveltejs/kit/http'; // eslint-disable-line import/no-unresolved +import { getRawBody } from '@sveltejs/kit/http'; // eslint-disable-line import/no-unresolved import '@sveltejs/kit/install-fetch'; // eslint-disable-line import/no-unresolved // App is a dynamic file built from the application layer. @@ -44,7 +44,7 @@ export function createServer({ render }) { method: req.method, headers: req.headers, // TODO: what about repeated headers, i.e. string[] path: parsed.pathname, - body: await get_body(req), + rawBody: await getRawBody(req), query: parsed.searchParams }); diff --git a/packages/adapter-vercel/files/entry.js b/packages/adapter-vercel/files/entry.js index 9d7a108dff0e..3488842f2174 100644 --- a/packages/adapter-vercel/files/entry.js +++ b/packages/adapter-vercel/files/entry.js @@ -1,4 +1,4 @@ -import { get_body } from '@sveltejs/kit/http'; // eslint-disable-line import/no-unresolved +import { getRawBody } from '@sveltejs/kit/http'; // eslint-disable-line import/no-unresolved import '@sveltejs/kit/install-fetch'; // eslint-disable-line import/no-unresolved // TODO hardcoding the relative location makes this brittle @@ -12,7 +12,7 @@ export default async (req, res) => { headers: req.headers, path: pathname, query: searchParams, - body: await get_body(req) + body: await getRawBody(req) }); if (rendered) { diff --git a/packages/adapter-vercel/package.json b/packages/adapter-vercel/package.json index e35e3abf19cd..b7c75d1b2ccf 100644 --- a/packages/adapter-vercel/package.json +++ b/packages/adapter-vercel/package.json @@ -10,9 +10,11 @@ "format": "prettier --write . --config ../../.prettierrc --ignore-path .gitignore", "check-format": "prettier --check . --config ../../.prettierrc --ignore-path .gitignore" }, - "devDependencies": { + "dependencies": { "@sveltejs/kit": "workspace:*", - "esbuild": "^0.11.12", + "esbuild": "^0.11.12" + }, + "devDependencies": { "typescript": "^4.2.3" } } diff --git a/packages/create-svelte/templates/default/.ignore b/packages/create-svelte/templates/default/.ignore index 40a014c08546..e131e29533e7 100644 --- a/packages/create-svelte/templates/default/.ignore +++ b/packages/create-svelte/templates/default/.ignore @@ -1,3 +1,4 @@ .meta.json package.json netlify.toml +wrangler.toml \ No newline at end of file diff --git a/packages/create-svelte/templates/default/wrangler.toml b/packages/create-svelte/templates/default/wrangler.toml new file mode 100644 index 000000000000..b98a9fce0a98 --- /dev/null +++ b/packages/create-svelte/templates/default/wrangler.toml @@ -0,0 +1,10 @@ +name = "svelte-kit-demo" +type = "javascript" +account_id = "f60df21486a4f0e5dbd85493882f1d53" +workers_dev = true +route = "" +zone_id = "" + +[site] +bucket = "./.cloudflare/assets" +entry-point = "./.cloudflare/worker" \ No newline at end of file diff --git a/packages/kit/src/core/adapt/prerender.js b/packages/kit/src/core/adapt/prerender.js index 3c51823e8175..cca396a9ed97 100644 --- a/packages/kit/src/core/adapt/prerender.js +++ b/packages/kit/src/core/adapt/prerender.js @@ -105,7 +105,7 @@ export async function prerender({ cwd, out, log, config, build_data, force }) { method: 'GET', headers: {}, path, - body: null, + rawBody: null, query: new URLSearchParams() }, { diff --git a/packages/kit/src/core/dev/index.js b/packages/kit/src/core/dev/index.js index b9433474c967..4bec1f1fa9bd 100644 --- a/packages/kit/src/core/dev/index.js +++ b/packages/kit/src/core/dev/index.js @@ -10,7 +10,7 @@ import create_manifest_data from '../../core/create_manifest_data/index.js'; import { create_app } from '../../core/create_app/index.js'; import { rimraf } from '../filesystem/index.js'; import { ssr } from '../../runtime/server/index.js'; -import { get_body } from '../http/index.js'; +import { getRawBody } from '../http/index.js'; import { copy_assets } from '../utils.js'; import svelte from '@sveltejs/vite-plugin-svelte'; import { get_server } from '../server/index.js'; @@ -146,7 +146,7 @@ class Watcher extends EventEmitter { return; } - const body = await get_body(req); + const rawBody = await getRawBody(req); const host = /** @type {string} */ (this.config.kit.host || req.headers[this.config.kit.hostHeader || 'host']); @@ -158,7 +158,7 @@ class Watcher extends EventEmitter { host, path: parsed.pathname, query: new URLSearchParams(parsed.query), - body + rawBody }, { amp: this.config.kit.amp, diff --git a/packages/kit/src/core/http/get_body/index.js b/packages/kit/src/core/http/get_body/index.js deleted file mode 100644 index cee4ec6f3932..000000000000 --- a/packages/kit/src/core/http/get_body/index.js +++ /dev/null @@ -1,171 +0,0 @@ -import { read_only_form_data } from './read_only_form_data.js'; - -/** @param {import('http').IncomingMessage} req */ -export function get_body(req) { - const headers = req.headers; - const has_body = - headers['content-type'] !== undefined && - // https://github.com/jshttp/type-is/blob/c1f4388c71c8a01f79934e68f630ca4a15fffcd6/index.js#L81-L95 - (headers['transfer-encoding'] !== undefined || !isNaN(Number(headers['content-length']))); - - if (!has_body) return Promise.resolve(undefined); - - const [type, ...directives] = headers['content-type'].split(/;\s*/); - - switch (type) { - case 'application/octet-stream': - return get_buffer(req); - - case 'text/plain': - return get_text(req); - - case 'application/json': - return get_json(req); - - case 'application/x-www-form-urlencoded': - return get_urlencoded(req); - - case 'multipart/form-data': { - const boundary = directives.find((directive) => directive.startsWith('boundary=')); - if (!boundary) throw new Error('Missing boundary'); - return get_multipart(req, boundary.slice('boundary='.length)); - } - default: - throw new Error(`Invalid Content-Type ${type}`); - } -} - -/** @param {import('http').IncomingMessage} req */ -async function get_json(req) { - return JSON.parse(await get_text(req)); -} - -/** @param {import('http').IncomingMessage} req */ -async function get_urlencoded(req) { - const text = await get_text(req); - - const { data, append } = read_only_form_data(); - - text - .replace(/\+/g, ' ') - .split('&') - .forEach((str) => { - const [key, value] = str.split('='); - append(decodeURIComponent(key), decodeURIComponent(value)); - }); - - return data; -} - -/** - * @param {import('http').IncomingMessage} req - * @param {string} boundary - */ -async function get_multipart(req, boundary) { - const text = await get_text(req); - const parts = text.split(`--${boundary}`); - - const nope = () => { - throw new Error('Malformed form data'); - }; - - if (parts[0] !== '' || parts[parts.length - 1].trim() !== '--') { - nope(); - } - - const { data, append } = read_only_form_data(); - - parts.slice(1, -1).forEach((part) => { - const match = /\s*([\s\S]+?)\r\n\r\n([\s\S]*)\s*/.exec(part); - const raw_headers = match[1]; - const body = match[2].trim(); - - let key; - - /** @type {Record} */ - const headers = {}; - raw_headers.split('\r\n').forEach((str) => { - const [raw_header, ...raw_directives] = str.split('; '); - let [name, value] = raw_header.split(': '); - - name = name.toLowerCase(); - headers[name] = value; - - /** @type {Record} */ - const directives = {}; - raw_directives.forEach((raw_directive) => { - const [name, value] = raw_directive.split('='); - directives[name] = JSON.parse(value); // TODO is this right? - }); - - if (name === 'content-disposition') { - if (value !== 'form-data') nope(); - - if (directives.filename) { - // TODO we probably don't want to do this automatically - throw new Error('File upload is not yet implemented'); - } - - if (directives.name) { - key = directives.name; - } - } - }); - - if (!key) nope(); - - append(key, body); - }); - - return data; -} - -/** - * @param {import('http').IncomingMessage} req - * @returns {Promise} - */ -function get_text(req) { - return new Promise((fulfil, reject) => { - let data = ''; - - req.on('error', reject); - - req.on('data', (chunk) => { - data += chunk; - }); - - req.on('end', () => { - fulfil(data); - }); - }); -} - -/** - * @param {import('http').IncomingMessage} req - * @returns {Promise} - */ -function get_buffer(req) { - return new Promise((fulfil, reject) => { - let data = new Uint8Array(0); - - req.on('error', reject); - - req.on('data', (chunk) => { - const new_data = new Uint8Array(data.length + chunk.length); - - for (let i = 0; i < data.length; i += 1) { - new_data[i] = data[i]; - } - - for (let i = 0; i < chunk.length; i += 1) { - new_data[i + data.length] = chunk[i]; - } - - data = new_data; - }); - - req.on('end', () => { - fulfil(data.buffer); - }); - }); -} diff --git a/packages/kit/src/core/http/index.js b/packages/kit/src/core/http/index.js index ba1ba9b88e3a..40831f4f95d8 100644 --- a/packages/kit/src/core/http/index.js +++ b/packages/kit/src/core/http/index.js @@ -1 +1,64 @@ -export { get_body } from './get_body/index.js'; +/** @param {import('http').IncomingMessage} req */ +export function getRawBody(req) { + return new Promise((fulfil, reject) => { + const h = req.headers; + + if (!h['content-type']) { + fulfil(null); + return; + } + + req.on('error', reject); + + const length = Number(h['content-length']); + + /** @type {Uint8Array} */ + let data; + + if (!isNaN(length)) { + data = new Uint8Array(length); + + let i = 0; + + req.on('data', (chunk) => { + // TODO maybe there's a simpler way to copy data between buffers? + for (let j = 0; j < chunk.length; j += 1) { + data[i++] = chunk[j]; + } + }); + } else { + // https://github.com/jshttp/type-is/blob/c1f4388c71c8a01f79934e68f630ca4a15fffcd6/index.js#L81-L95 + if (h['transfer-encoding'] === undefined) { + fulfil(null); + return; + } + + data = new Uint8Array(0); + + req.on('data', (chunk) => { + const new_data = new Uint8Array(data.length + chunk.length); + + for (let i = 0; i < data.length; i += 1) { + new_data[i] = data[i]; + } + + for (let i = 0; i < chunk.length; i += 1) { + new_data[i + data.length] = chunk[i]; + } + + data = new_data; + }); + } + + req.on('end', () => { + const [type] = h['content-type'].split(/;\s*/); + + if (type === 'application/octet-stream') { + fulfil(data.buffer); + } + + const decoder = new TextDecoder(h['content-encoding'] || 'utf-8'); + fulfil(decoder.decode(data)); + }); + }); +} diff --git a/packages/kit/src/core/start/index.js b/packages/kit/src/core/start/index.js index 5fedb0f76cf1..1bcd875abdf7 100644 --- a/packages/kit/src/core/start/index.js +++ b/packages/kit/src/core/start/index.js @@ -1,7 +1,7 @@ import fs from 'fs'; import { parse, pathToFileURL } from 'url'; import sirv from 'sirv'; -import { get_body } from '../http/index.js'; +import { getRawBody } from '../http/index.js'; import { join, resolve } from 'path'; import { get_server } from '../server/index.js'; import '../../install-fetch.js'; @@ -58,7 +58,7 @@ export async function start({ port, host, config, https: use_https = false, cwd method: req.method, headers: /** @type {import('types/helper').Headers} */ (req.headers), path: parsed.pathname, - body: await get_body(req), + rawBody: await getRawBody(req), query: new URLSearchParams(parsed.query || '') }); diff --git a/packages/kit/src/runtime/server/index.js b/packages/kit/src/runtime/server/index.js index 27c1426a0fb7..ebc61ee36cc9 100644 --- a/packages/kit/src/runtime/server/index.js +++ b/packages/kit/src/runtime/server/index.js @@ -1,6 +1,7 @@ import { createHash } from 'crypto'; import render_page from './page/index.js'; import render_endpoint from './endpoint.js'; +import { parse_body } from './parse_body/index.js'; /** @param {string} body */ function md5(body) { @@ -24,12 +25,17 @@ export async function ssr(incoming, options, state = {}) { }; } - const context = (await options.hooks.getContext(incoming)) || {}; + const incoming_with_body = { + ...incoming, + body: parse_body(incoming) + }; + + const context = (await options.hooks.getContext(incoming_with_body)) || {}; try { return await options.hooks.handle({ request: { - ...incoming, + ...incoming_with_body, params: null, context }, diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js index c151d31d9bca..62f07481b7d1 100644 --- a/packages/kit/src/runtime/server/page/load_node.js +++ b/packages/kit/src/runtime/server/page/load_node.js @@ -147,7 +147,10 @@ export async function load_node({ method: opts.method || 'GET', headers, path: resolved, - body: /** @type {any} */ (opts.body), + // TODO per https://developer.mozilla.org/en-US/docs/Web/API/Request/Request, this can be a + // Blob, BufferSource, FormData, URLSearchParams, USVString, or ReadableStream object + // @ts-ignore + rawBody: opts.body, query: new URLSearchParams(parsed.query || '') }, options, diff --git a/packages/kit/src/runtime/server/parse_body/index.js b/packages/kit/src/runtime/server/parse_body/index.js new file mode 100644 index 000000000000..85265de20989 --- /dev/null +++ b/packages/kit/src/runtime/server/parse_body/index.js @@ -0,0 +1,109 @@ +import { read_only_form_data } from './read_only_form_data.js'; + +/** @param {import('types/hooks').Incoming} req */ +export function parse_body(req) { + const raw = req.rawBody; + if (!raw) return raw; + + const [type, ...directives] = req.headers['content-type'].split(/;\s*/); + + if (typeof raw === 'string') { + switch (type) { + case 'text/plain': + return raw; + + case 'application/json': + return JSON.parse(raw); + + case 'application/x-www-form-urlencoded': + return get_urlencoded(raw); + + case 'multipart/form-data': { + const boundary = directives.find((directive) => directive.startsWith('boundary=')); + if (!boundary) throw new Error('Missing boundary'); + return get_multipart(raw, boundary.slice('boundary='.length)); + } + default: + throw new Error(`Invalid Content-Type ${type}`); + } + } + + return raw; +} + +/** @param {string} text */ +function get_urlencoded(text) { + const { data, append } = read_only_form_data(); + + text + .replace(/\+/g, ' ') + .split('&') + .forEach((str) => { + const [key, value] = str.split('='); + append(decodeURIComponent(key), decodeURIComponent(value)); + }); + + return data; +} + +/** + * @param {string} text + * @param {string} boundary + */ +function get_multipart(text, boundary) { + const parts = text.split(`--${boundary}`); + + const nope = () => { + throw new Error('Malformed form data'); + }; + + if (parts[0] !== '' || parts[parts.length - 1].trim() !== '--') { + nope(); + } + + const { data, append } = read_only_form_data(); + + parts.slice(1, -1).forEach((part) => { + const match = /\s*([\s\S]+?)\r\n\r\n([\s\S]*)\s*/.exec(part); + const raw_headers = match[1]; + const body = match[2].trim(); + + let key; + + /** @type {Record} */ + const headers = {}; + raw_headers.split('\r\n').forEach((str) => { + const [raw_header, ...raw_directives] = str.split('; '); + let [name, value] = raw_header.split(': '); + + name = name.toLowerCase(); + headers[name] = value; + + /** @type {Record} */ + const directives = {}; + raw_directives.forEach((raw_directive) => { + const [name, value] = raw_directive.split('='); + directives[name] = JSON.parse(value); // TODO is this right? + }); + + if (name === 'content-disposition') { + if (value !== 'form-data') nope(); + + if (directives.filename) { + // TODO we probably don't want to do this automatically + throw new Error('File upload is not yet implemented'); + } + + if (directives.name) { + key = directives.name; + } + } + }); + + if (!key) nope(); + + append(key, body); + }); + + return data; +} diff --git a/packages/kit/src/core/http/get_body/read_only_form_data.js b/packages/kit/src/runtime/server/parse_body/read_only_form_data.js similarity index 100% rename from packages/kit/src/core/http/get_body/read_only_form_data.js rename to packages/kit/src/runtime/server/parse_body/read_only_form_data.js diff --git a/packages/kit/test/apps/basics/src/routes/load/__tests__.js b/packages/kit/test/apps/basics/src/routes/load/__tests__.js index ae658a491804..203aafdf73ef 100644 --- a/packages/kit/test/apps/basics/src/routes/load/__tests__.js +++ b/packages/kit/test/apps/basics/src/routes/load/__tests__.js @@ -142,4 +142,11 @@ export default function (test, is_dev) { assert.equal(await page.textContent('h1'), 'Hello SvelteKit!'); } ); + + test('exposes rawBody to endpoints', '/load', async ({ page, clicknav }) => { + await clicknav('[href="/load/raw-body"]'); + + assert.equal(await page.innerHTML('.parsed'), '{"oddly":{"formatted":"json"}}'); + assert.equal(await page.innerHTML('.raw'), '{ "oddly" : { "formatted" : "json" } }'); + }); } diff --git a/packages/kit/test/apps/basics/src/routes/load/index.svelte b/packages/kit/test/apps/basics/src/routes/load/index.svelte index cadb56255838..155b3523ec8b 100644 --- a/packages/kit/test/apps/basics/src/routes/load/index.svelte +++ b/packages/kit/test/apps/basics/src/routes/load/index.svelte @@ -21,3 +21,4 @@ fetch request fetch credentialed large response +raw body diff --git a/packages/kit/test/apps/basics/src/routes/load/raw-body.json.js b/packages/kit/test/apps/basics/src/routes/load/raw-body.json.js new file mode 100644 index 000000000000..63a9315b133e --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/raw-body.json.js @@ -0,0 +1,9 @@ +/** @type {import('../../../../../../types').RequestHandler} */ +export function post(request) { + return { + body: { + body: request.body, + rawBody: request.rawBody + } + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/raw-body.svelte b/packages/kit/test/apps/basics/src/routes/load/raw-body.svelte new file mode 100644 index 000000000000..226058cdea3d --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/raw-body.svelte @@ -0,0 +1,27 @@ + + + + +
{JSON.stringify(body)}
+
{rawBody}
\ No newline at end of file diff --git a/packages/kit/types/hooks.d.ts b/packages/kit/types/hooks.d.ts index c2f4e40517b2..a6ceb1d425db 100644 --- a/packages/kit/types/hooks.d.ts +++ b/packages/kit/types/hooks.d.ts @@ -7,7 +7,8 @@ export type Incoming = { headers: Headers; path: string; query: URLSearchParams; - body: BaseBody; + rawBody: string | ArrayBuffer; + body?: BaseBody; }; export type GetContext = (incoming: Incoming) => Context; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3188f6545b11..d8e2abe0a344 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -84,8 +84,10 @@ importers: packages/adapter-cloudflare-workers: specifiers: '@sveltejs/kit': workspace:* + esbuild: ^0.11.12 toml: ^3.0.0 dependencies: + esbuild: 0.11.12 toml: 3.0.0 devDependencies: '@sveltejs/kit': link:../kit @@ -93,12 +95,14 @@ importers: packages/adapter-netlify: specifiers: '@sveltejs/kit': workspace:* + esbuild: ^0.11.12 toml: ^3.0.0 typescript: ^4.2.3 dependencies: + '@sveltejs/kit': link:../kit + esbuild: 0.11.12 toml: 3.0.0 devDependencies: - '@sveltejs/kit': link:../kit typescript: 4.2.3 packages/adapter-node: @@ -138,9 +142,10 @@ importers: '@sveltejs/kit': workspace:* esbuild: ^0.11.12 typescript: ^4.2.3 - devDependencies: + dependencies: '@sveltejs/kit': link:../kit esbuild: 0.11.12 + devDependencies: typescript: 4.2.3 packages/create-svelte: @@ -1509,7 +1514,7 @@ packages: resolution: {integrity: sha512-c8cso/1RwVj+fbDvLtUgSG4ZJQ0y9Zdrl6Ot/GAjyy4pdMCHaFnDMts5gqFnWRPLajWtEnI+3hlET4R9fVoZng==} hasBin: true requiresBuild: true - dev: true + dev: false /esbuild/0.9.2: resolution: {integrity: sha512-xE3oOILjnmN8PSjkG3lT9NBbd1DbxNqolJ5qNyrLhDWsFef3yTp/KTQz1C/x7BYFKbtrr9foYtKA6KA1zuNAUQ==}