diff --git a/.changeset/fifty-tigers-turn.md b/.changeset/fifty-tigers-turn.md new file mode 100644 index 000000000000..c4ac2d73eff8 --- /dev/null +++ b/.changeset/fifty-tigers-turn.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': minor +--- + +feat: inline `response.arrayBuffer()` during ssr diff --git a/documentation/docs/20-core-concepts/20-load.md b/documentation/docs/20-core-concepts/20-load.md index 9637dca656eb..b5cace32f0b7 100644 --- a/documentation/docs/20-core-concepts/20-load.md +++ b/documentation/docs/20-core-concepts/20-load.md @@ -234,7 +234,7 @@ To get data from an external API or a `+server.js` handler, you can use the prov - It can be used to make credentialed requests on the server, as it inherits the `cookie` and `authorization` headers for the page request. - It can make relative requests on the server (ordinarily, `fetch` requires a URL with an origin when used in a server context). - Internal requests (e.g. for `+server.js` routes) go directly to the handler function when running on the server, without the overhead of an HTTP call. -- During server-side rendering, the response will be captured and inlined into the rendered HTML by hooking into the `text` and `json` methods of the `Response` object. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](hooks#server-hooks-handle). +- During server-side rendering, the response will be captured and inlined into the rendered HTML by hooking into the `text`, `json` and `arrayBuffer` methods of the `Response` object. Note that headers will _not_ be serialized, unless explicitly included via [`filterSerializedResponseHeaders`](hooks#server-hooks-handle). - During hydration, the response will be read from the HTML, guaranteeing consistency and preventing an additional network request - if you received a warning in your browser console when using the browser `fetch` instead of the `load` `fetch`, this is why. ```js diff --git a/packages/kit/src/runtime/client/fetcher.js b/packages/kit/src/runtime/client/fetcher.js index df709d1f3968..3c53027cf40d 100644 --- a/packages/kit/src/runtime/client/fetcher.js +++ b/packages/kit/src/runtime/client/fetcher.js @@ -67,6 +67,22 @@ if (DEV) { const cache = new Map(); +/** + * @param {string} text + * @returns {ArrayBufferLike} + */ +function b64_decode(text) { + const d = atob(text); + + const u8 = new Uint8Array(d.length); + + for (let i = 0; i < d.length; i++) { + u8[i] = d.charCodeAt(i); + } + + return u8.buffer; +} + /** * Should be called on the initial run of load functions that hydrate the page. * Saves any requests with cache-control max-age to the cache. @@ -78,10 +94,16 @@ export function initial_fetch(resource, opts) { const script = document.querySelector(selector); if (script?.textContent) { - const { body, ...init } = JSON.parse(script.textContent); + let { body, ...init } = JSON.parse(script.textContent); const ttl = script.getAttribute('data-ttl'); if (ttl) cache.set(selector, { body, init, ttl: 1000 * Number(ttl) }); + const b64 = script.getAttribute('data-b64'); + if (b64 !== null) { + // Can't use native_fetch('data:...;base64,${body}') + // csp can block the request + body = b64_decode(body); + } return Promise.resolve(new Response(body, init)); } diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index bcc2443aede6..451a9912ff42 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -189,6 +189,25 @@ export async function load_data({ return data; } +/** + * @param {ArrayBuffer} buffer + * @returns {string} + */ +function b64_encode(buffer) { + if (globalThis.Buffer) { + return Buffer.from(buffer).toString('base64'); + } + + const little_endian = new Uint8Array(new Uint16Array([1]).buffer)[0] > 0; + + // The Uint16Array(Uint8Array(...)) ensures the code points are padded with 0's + return btoa( + new TextDecoder(little_endian ? 'utf-16le' : 'utf-16be').decode( + new Uint16Array(new Uint8Array(buffer)) + ) + ); +} + /** * @param {Pick} event * @param {import('types').SSRState} state @@ -245,38 +264,33 @@ export function create_universal_fetch(event, state, fetched, csr, resolve_opts) const proxy = new Proxy(response, { get(response, key, _receiver) { - async function text() { - const body = await response.text(); - - if (!body || typeof body === 'string') { - const status_number = Number(response.status); - if (isNaN(status_number)) { - throw new Error( - `response.status is not a number. value: "${ - response.status - }" type: ${typeof response.status}` - ); - } - - fetched.push({ - url: same_origin ? url.href.slice(event.url.origin.length) : url.href, - method: event.request.method, - request_body: /** @type {string | ArrayBufferView | undefined} */ ( - input instanceof Request && cloned_body - ? await stream_to_string(cloned_body) - : init?.body - ), - request_headers: cloned_headers, - response_body: body, - response - }); - } - - if (dependency) { - dependency.body = body; + /** + * @param {string} body + * @param {boolean} is_b64 + */ + async function push_fetched(body, is_b64) { + const status_number = Number(response.status); + if (isNaN(status_number)) { + throw new Error( + `response.status is not a number. value: "${ + response.status + }" type: ${typeof response.status}` + ); } - return body; + fetched.push({ + url: same_origin ? url.href.slice(event.url.origin.length) : url.href, + method: event.request.method, + request_body: /** @type {string | ArrayBufferView | undefined} */ ( + input instanceof Request && cloned_body + ? await stream_to_string(cloned_body) + : init?.body + ), + request_headers: cloned_headers, + response_body: body, + response, + is_b64 + }); } if (key === 'arrayBuffer') { @@ -287,13 +301,28 @@ export function create_universal_fetch(event, state, fetched, csr, resolve_opts) dependency.body = new Uint8Array(buffer); } - // TODO should buffer be inlined into the page (albeit base64'd)? - // any conditions in which it shouldn't be? + if (buffer instanceof ArrayBuffer) { + await push_fetched(b64_encode(buffer), true); + } return buffer; }; } + async function text() { + const body = await response.text(); + + if (!body || typeof body === 'string') { + await push_fetched(body, false); + } + + if (dependency) { + dependency.body = body; + } + + return body; + } + if (key === 'text') { return text; } diff --git a/packages/kit/src/runtime/server/page/serialize_data.js b/packages/kit/src/runtime/server/page/serialize_data.js index b898a8a4e910..f879a50b3156 100644 --- a/packages/kit/src/runtime/server/page/serialize_data.js +++ b/packages/kit/src/runtime/server/page/serialize_data.js @@ -73,6 +73,10 @@ export function serialize_data(fetched, filter, prerendering = false) { `data-url=${escape_html_attr(fetched.url)}` ]; + if (fetched.is_b64) { + attrs.push('data-b64'); + } + if (fetched.request_headers || fetched.request_body) { /** @type {import('types').StrictBody[]} */ const values = []; diff --git a/packages/kit/src/runtime/server/page/types.d.ts b/packages/kit/src/runtime/server/page/types.d.ts index f5b0c2d77e7a..3b9fef9c3a3f 100644 --- a/packages/kit/src/runtime/server/page/types.d.ts +++ b/packages/kit/src/runtime/server/page/types.d.ts @@ -8,6 +8,7 @@ export interface Fetched { request_headers?: HeadersInit | undefined; response_body: string; response: Response; + is_b64?: boolean; } export type Loaded = { diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/+page.js b/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/+page.js new file mode 100644 index 000000000000..2549f6bace21 --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/+page.js @@ -0,0 +1,13 @@ +export async function load({ fetch }) { + const res = await fetch('/load/fetch-arraybuffer-b64/data'); + + const l = await fetch('/load/fetch-arraybuffer-b64/data', { + body: Uint8Array.from(Array(256).fill(0), (_, i) => i), + method: 'POST' + }); + + return { + data: res.arrayBuffer(), + data_long: l.arrayBuffer() + }; +} diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/+page.svelte b/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/+page.svelte new file mode 100644 index 000000000000..bf3c9341bc8b --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/+page.svelte @@ -0,0 +1,30 @@ + + +{JSON.stringify(arr)} + +
+ +{ok} + + {JSON.stringify([...new Uint8Array(data.data_long)])} + diff --git a/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/data/+server.js b/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/data/+server.js new file mode 100644 index 000000000000..c866fb28c2fd --- /dev/null +++ b/packages/kit/test/apps/basics/src/routes/load/fetch-arraybuffer-b64/data/+server.js @@ -0,0 +1,7 @@ +export const GET = () => { + return new Response(new Uint8Array([1, 2, 3, 4])); +}; + +export const POST = async ({ request }) => { + return new Response(await request.arrayBuffer()); +}; diff --git a/packages/kit/test/apps/basics/test/test.js b/packages/kit/test/apps/basics/test/test.js index e4a4af7514a8..6381eb9112ab 100644 --- a/packages/kit/test/apps/basics/test/test.js +++ b/packages/kit/test/apps/basics/test/test.js @@ -275,6 +275,28 @@ test.describe('Load', () => { } }); + test('fetches using an arraybuffer serialized with b64', async ({ page, javaScriptEnabled }) => { + await page.goto('/load/fetch-arraybuffer-b64'); + + expect(await page.textContent('.test-content')).toBe('[1,2,3,4]'); + + if (!javaScriptEnabled) { + const payload = '{"status":200,"statusText":"","headers":{},"body":"AQIDBA=="}'; + const post_payload = + '{"status":200,"statusText":"","headers":{},"body":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w=="}'; + + const script_content = await page.innerHTML( + 'script[data-sveltekit-fetched][data-b64][data-url="/load/fetch-arraybuffer-b64/data"]' + ); + const post_script_content = await page.innerHTML( + 'script[data-sveltekit-fetched][data-b64][data-url="/load/fetch-arraybuffer-b64/data"][data-hash="16h3sp1"]' + ); + + expect(script_content).toBe(payload); + expect(post_script_content).toBe(post_payload); + } + }); + test('json string is returned', async ({ page }) => { await page.goto('/load/relay'); expect(await page.textContent('h1')).toBe('42');