From 5378cdf01edd52f62d7666ab2e9255ce32f4607f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 8 May 2021 16:20:07 -0400 Subject: [PATCH 1/2] account for POST bodies when serializing fetches - fixes #633 --- .changeset/young-apricots-dance.md | 5 +++ packages/kit/src/runtime/client/renderer.js | 12 +++++-- packages/kit/src/runtime/hash.js | 13 +++++++ .../kit/src/runtime/server/page/load_node.js | 14 +++++--- .../kit/src/runtime/server/page/render.js | 9 +++-- .../kit/src/runtime/server/page/types.d.ts | 2 +- .../apps/basics/src/routes/load/_tests.js | 36 ++++++++++++++++++- .../routes/load/serialization-post.json.js | 6 ++++ .../src/routes/load/serialization-post.svelte | 34 ++++++++++++++++++ 9 files changed, 120 insertions(+), 11 deletions(-) create mode 100644 .changeset/young-apricots-dance.md create mode 100644 packages/kit/src/runtime/hash.js create mode 100644 packages/kit/test/apps/basics/src/routes/load/serialization-post.json.js create mode 100644 packages/kit/test/apps/basics/src/routes/load/serialization-post.svelte diff --git a/.changeset/young-apricots-dance.md b/.changeset/young-apricots-dance.md new file mode 100644 index 000000000000..8e6c722f9d2b --- /dev/null +++ b/.changeset/young-apricots-dance.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +Account for POST bodies when serializing fetches diff --git a/packages/kit/src/runtime/client/renderer.js b/packages/kit/src/runtime/client/renderer.js index b1165012f2c3..047671e201d1 100644 --- a/packages/kit/src/runtime/client/renderer.js +++ b/packages/kit/src/runtime/client/renderer.js @@ -1,5 +1,6 @@ import { writable } from 'svelte/store'; -import { normalize } from '../load'; +import { hash } from '../hash.js'; +import { normalize } from '../load.js'; /** @param {any} value */ function page_store(value) { @@ -37,7 +38,14 @@ function page_store(value) { */ function initial_fetch(resource, opts) { const url = typeof resource === 'string' ? resource : resource.url; - const script = document.querySelector(`script[type="svelte-data"][url="${url}"]`); + + let selector = `script[type="svelte-data"][url="${url}"]`; + + if (opts && typeof opts.body === 'string') { + selector += `[body="${hash(opts.body)}"]`; + } + + const script = document.querySelector(selector); if (script) { const { body, ...init } = JSON.parse(script.textContent); return Promise.resolve(new Response(body, init)); diff --git a/packages/kit/src/runtime/hash.js b/packages/kit/src/runtime/hash.js new file mode 100644 index 000000000000..d88fa0a56f50 --- /dev/null +++ b/packages/kit/src/runtime/hash.js @@ -0,0 +1,13 @@ +/** @param {string | Uint8Array} value */ +export function hash(value) { + let hash = 5381; + let i = value.length; + + if (typeof value === 'string') { + while (i) hash = (hash * 33) ^ value.charCodeAt(--i); + } else { + while (i) hash = (hash * 33) ^ value[--i]; + } + + return (hash >>> 0).toString(36); +} diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js index a8b94646dd06..03fc5ea69a7a 100644 --- a/packages/kit/src/runtime/server/page/load_node.js +++ b/packages/kit/src/runtime/server/page/load_node.js @@ -42,6 +42,7 @@ export async function load_node({ /** @type {Array<{ * url: string; + * body: string; * json: string; * }>} */ const fetched = []; @@ -189,11 +190,14 @@ export async function load_node({ if (key !== 'etag' && key !== 'set-cookie') headers[key] = value; } - // prettier-ignore - fetched.push({ - url, - json: `{"status":${response.status},"statusText":${s(response.statusText)},"headers":${s(headers)},"body":${escape(body)}}` - }); + if (!opts.body || typeof opts.body === 'string') { + // prettier-ignore + fetched.push({ + url, + body: /** @type {string} */ (opts.body), + json: `{"status":${response.status},"statusText":${s(response.statusText)},"headers":${s(headers)},"body":${escape(body)}}` + }); + } return body; } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 04f2217620d1..f7ffc96b5d8c 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -1,5 +1,6 @@ import devalue from 'devalue'; import { writable } from 'svelte/store'; +import { hash } from '../../hash.js'; const s = JSON.stringify; @@ -29,7 +30,7 @@ export async function render_response({ const js = new Set(options.entry.js); const styles = new Set(); - /** @type {Array<{ url: string, json: string }>} */ + /** @type {Array<{ url: string, body: string, json: string }>} */ const serialized_data = []; let rendered; @@ -156,7 +157,11 @@ export async function render_response({ : `${rendered.html} ${serialized_data - .map(({ url, json }) => ``) + .map(({ url, body, json }) => { + return body + ? `` + : ``; + }) .join('\n\n\t\t\t')} `.replace(/^\t{2}/gm, ''); diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index 7c4a653ab977..cc7cf9dfa801 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -4,6 +4,6 @@ export type Loaded = { node: SSRNode; loaded: NormalizedLoadOutput; context: Record; - fetched: Array<{ url: string; json: string }>; + fetched: Array<{ url: string; body: string; json: string }>; uses_credentials: boolean; }; 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 f1e321e9e209..6c60df9382c4 100644 --- a/packages/kit/test/apps/basics/src/routes/load/_tests.js +++ b/packages/kit/test/apps/basics/src/routes/load/_tests.js @@ -8,7 +8,7 @@ export default function (test, is_dev) { assert.equal(await page.textContent('h1'), 'bar == bar?'); }); - test('data is serialized', null, async ({ base, page, capture_requests, js }) => { + test('GET fetches are serialized', null, async ({ base, page, capture_requests, js }) => { const requests = await capture_requests(async () => { await page.goto(`${base}/load/serialization`); }); @@ -29,6 +29,40 @@ export default function (test, is_dev) { ); }); + test('POST fetches are serialized', null, async ({ base, page, capture_requests, js }) => { + const requests = await capture_requests(async () => { + await page.goto(`${base}/load/serialization-post`); + }); + + assert.equal(await page.textContent('h1'), 'a: X'); + assert.equal(await page.textContent('h2'), 'b: Y'); + + const payload_a = + '{"status":200,"statusText":"","headers":{"content-type":"text/plain;charset=UTF-8"},"body":"X"}'; + + const payload_b = + '{"status":200,"statusText":"","headers":{"content-type":"text/plain;charset=UTF-8"},"body":"Y"}'; + + if (!js) { + // by the time JS has run, hydration will have nuked these scripts + const script_contents_a = await page.innerHTML( + 'script[type="svelte-data"][url="/load/serialization-post.json"][body="3t25"]' + ); + + const script_contents_b = await page.innerHTML( + 'script[type="svelte-data"][url="/load/serialization-post.json"][body="3t24"]' + ); + + assert.equal(script_contents_a, payload_a, 'Page should contain serialized data'); + assert.equal(script_contents_b, payload_b, 'Page should contain serialized data'); + } + + assert.ok( + !requests.some((r) => r.endsWith('/load/serialization.json')), + 'Should not load JSON over the wire' + ); + }); + test('json string is returned', '/load/relay', async ({ page }) => { assert.equal(await page.textContent('h1'), '42'); }); diff --git a/packages/kit/test/apps/basics/src/routes/load/serialization-post.json.js b/packages/kit/test/apps/basics/src/routes/load/serialization-post.json.js new file mode 100644 index 000000000000..5ea0ae3ff398 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/serialization-post.json.js @@ -0,0 +1,6 @@ +/** @type {import('@sveltejs/kit').RequestHandler} */ +export function post(request) { + return { + body: request.body.toUpperCase() + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/serialization-post.svelte b/packages/kit/test/apps/basics/src/routes/load/serialization-post.svelte new file mode 100644 index 000000000000..e4162fffafd7 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/serialization-post.svelte @@ -0,0 +1,34 @@ + + + + +

a: {a}

+

b: {b}

From de5907ec3159be13065e55034141efbcec76801f Mon Sep 17 00:00:00 2001 From: Rich Harris Date: Sat, 8 May 2021 16:32:31 -0400 Subject: [PATCH 2/2] disallow non-string bodies in fetch in load --- packages/kit/src/runtime/server/page/load_node.js | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/runtime/server/page/load_node.js b/packages/kit/src/runtime/server/page/load_node.js index 03fc5ea69a7a..0c728e6df4e5 100644 --- a/packages/kit/src/runtime/server/page/load_node.js +++ b/packages/kit/src/runtime/server/page/load_node.js @@ -146,16 +146,21 @@ export async function load_node({ } } + if (opts.body && typeof opts.body !== 'string') { + // per https://developer.mozilla.org/en-US/docs/Web/API/Request/Request, this can be a + // Blob, BufferSource, FormData, URLSearchParams, USVString, or ReadableStream object. + // non-string bodies are irksome to deal with, but luckily aren't particularly useful + // in this context anyway, so we take the easy route and ban them + throw new Error('Request body must be a string'); + } + const rendered = await respond( { host: request.host, method: opts.method || 'GET', headers, path: resolved, - // 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, + rawBody: /** @type {string} */ (opts.body), query: new URLSearchParams(search) }, options,