Skip to content

Commit 594568e

Browse files
Elia872Rich-Harris
andauthored
feat: inline Response.arrayBuffer inside load functions during ssr (#10535)
* add tests * encode arraybuffer with b64 * add arrayBuffer to doc * changeset * removed dependency * big endian * format * Update packages/kit/src/runtime/server/page/load_data.js * Update .changeset/fifty-tigers-turn.md --------- Co-authored-by: Rich Harris <[email protected]>
1 parent d7ba3bf commit 594568e

File tree

10 files changed

+167
-34
lines changed

10 files changed

+167
-34
lines changed

.changeset/fifty-tigers-turn.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@sveltejs/kit': minor
3+
---
4+
5+
feat: inline `response.arrayBuffer()` during ssr

documentation/docs/20-core-concepts/20-load.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -234,7 +234,7 @@ To get data from an external API or a `+server.js` handler, you can use the prov
234234
- It can be used to make credentialed requests on the server, as it inherits the `cookie` and `authorization` headers for the page request.
235235
- It can make relative requests on the server (ordinarily, `fetch` requires a URL with an origin when used in a server context).
236236
- 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.
237-
- 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).
237+
- 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).
238238
- 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.
239239

240240
```js

packages/kit/src/runtime/client/fetcher.js

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,22 @@ if (DEV) {
7575

7676
const cache = new Map();
7777

78+
/**
79+
* @param {string} text
80+
* @returns {ArrayBufferLike}
81+
*/
82+
function b64_decode(text) {
83+
const d = atob(text);
84+
85+
const u8 = new Uint8Array(d.length);
86+
87+
for (let i = 0; i < d.length; i++) {
88+
u8[i] = d.charCodeAt(i);
89+
}
90+
91+
return u8.buffer;
92+
}
93+
7894
/**
7995
* Should be called on the initial run of load functions that hydrate the page.
8096
* Saves any requests with cache-control max-age to the cache.
@@ -86,10 +102,16 @@ export function initial_fetch(resource, opts) {
86102

87103
const script = document.querySelector(selector);
88104
if (script?.textContent) {
89-
const { body, ...init } = JSON.parse(script.textContent);
105+
let { body, ...init } = JSON.parse(script.textContent);
90106

91107
const ttl = script.getAttribute('data-ttl');
92108
if (ttl) cache.set(selector, { body, init, ttl: 1000 * Number(ttl) });
109+
const b64 = script.getAttribute('data-b64');
110+
if (b64 !== null) {
111+
// Can't use native_fetch('data:...;base64,${body}')
112+
// csp can block the request
113+
body = b64_decode(body);
114+
}
93115

94116
return Promise.resolve(new Response(body, init));
95117
}

packages/kit/src/runtime/server/page/load_data.js

Lines changed: 61 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,25 @@ export async function load_data({
189189
return data;
190190
}
191191

192+
/**
193+
* @param {ArrayBuffer} buffer
194+
* @returns {string}
195+
*/
196+
function b64_encode(buffer) {
197+
if (globalThis.Buffer) {
198+
return Buffer.from(buffer).toString('base64');
199+
}
200+
201+
const little_endian = new Uint8Array(new Uint16Array([1]).buffer)[0] > 0;
202+
203+
// The Uint16Array(Uint8Array(...)) ensures the code points are padded with 0's
204+
return btoa(
205+
new TextDecoder(little_endian ? 'utf-16le' : 'utf-16be').decode(
206+
new Uint16Array(new Uint8Array(buffer))
207+
)
208+
);
209+
}
210+
192211
/**
193212
* @param {Pick<import('@sveltejs/kit').RequestEvent, 'fetch' | 'url' | 'request' | 'route'>} event
194213
* @param {import('types').SSRState} state
@@ -246,38 +265,33 @@ export function create_universal_fetch(event, state, fetched, csr, resolve_opts)
246265

247266
const proxy = new Proxy(response, {
248267
get(response, key, _receiver) {
249-
async function text() {
250-
const body = await response.text();
251-
252-
if (!body || typeof body === 'string') {
253-
const status_number = Number(response.status);
254-
if (isNaN(status_number)) {
255-
throw new Error(
256-
`response.status is not a number. value: "${
257-
response.status
258-
}" type: ${typeof response.status}`
259-
);
260-
}
261-
262-
fetched.push({
263-
url: same_origin ? url.href.slice(event.url.origin.length) : url.href,
264-
method: event.request.method,
265-
request_body: /** @type {string | ArrayBufferView | undefined} */ (
266-
input instanceof Request && cloned_body
267-
? await stream_to_string(cloned_body)
268-
: init?.body
269-
),
270-
request_headers: cloned_headers,
271-
response_body: body,
272-
response
273-
});
274-
}
275-
276-
if (dependency) {
277-
dependency.body = body;
268+
/**
269+
* @param {string} body
270+
* @param {boolean} is_b64
271+
*/
272+
async function push_fetched(body, is_b64) {
273+
const status_number = Number(response.status);
274+
if (isNaN(status_number)) {
275+
throw new Error(
276+
`response.status is not a number. value: "${
277+
response.status
278+
}" type: ${typeof response.status}`
279+
);
278280
}
279281

280-
return body;
282+
fetched.push({
283+
url: same_origin ? url.href.slice(event.url.origin.length) : url.href,
284+
method: event.request.method,
285+
request_body: /** @type {string | ArrayBufferView | undefined} */ (
286+
input instanceof Request && cloned_body
287+
? await stream_to_string(cloned_body)
288+
: init?.body
289+
),
290+
request_headers: cloned_headers,
291+
response_body: body,
292+
response,
293+
is_b64
294+
});
281295
}
282296

283297
if (key === 'arrayBuffer') {
@@ -288,13 +302,28 @@ export function create_universal_fetch(event, state, fetched, csr, resolve_opts)
288302
dependency.body = new Uint8Array(buffer);
289303
}
290304

291-
// TODO should buffer be inlined into the page (albeit base64'd)?
292-
// any conditions in which it shouldn't be?
305+
if (buffer instanceof ArrayBuffer) {
306+
await push_fetched(b64_encode(buffer), true);
307+
}
293308

294309
return buffer;
295310
};
296311
}
297312

313+
async function text() {
314+
const body = await response.text();
315+
316+
if (!body || typeof body === 'string') {
317+
await push_fetched(body, false);
318+
}
319+
320+
if (dependency) {
321+
dependency.body = body;
322+
}
323+
324+
return body;
325+
}
326+
298327
if (key === 'text') {
299328
return text;
300329
}

packages/kit/src/runtime/server/page/serialize_data.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,10 @@ export function serialize_data(fetched, filter, prerendering = false) {
7373
`data-url=${escape_html_attr(fetched.url)}`
7474
];
7575

76+
if (fetched.is_b64) {
77+
attrs.push('data-b64');
78+
}
79+
7680
if (fetched.request_headers || fetched.request_body) {
7781
/** @type {import('types').StrictBody[]} */
7882
const values = [];

packages/kit/src/runtime/server/page/types.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export interface Fetched {
88
request_headers?: HeadersInit | undefined;
99
response_body: string;
1010
response: Response;
11+
is_b64?: boolean;
1112
}
1213

1314
export type Loaded = {
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export async function load({ fetch }) {
2+
const res = await fetch('/load/fetch-arraybuffer-b64/data');
3+
4+
const l = await fetch('/load/fetch-arraybuffer-b64/data', {
5+
body: Uint8Array.from(Array(256).fill(0), (_, i) => i),
6+
method: 'POST'
7+
});
8+
9+
return {
10+
data: res.arrayBuffer(),
11+
data_long: l.arrayBuffer()
12+
};
13+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
<script>
2+
export let data;
3+
4+
$: arr = [...new Uint8Array(data.data)];
5+
6+
let ok = 'Ok';
7+
8+
$: {
9+
const p = new Uint8Array(data.data_long);
10+
ok = p.length === 256 ? 'Ok' : 'Wrong length';
11+
12+
if (p.length === 256) {
13+
for (let i = 0; i < p.length; i++) {
14+
if (p[i] !== i) {
15+
ok = `Expected ${i} but got ${p[i]}`;
16+
break;
17+
}
18+
}
19+
}
20+
}
21+
</script>
22+
23+
<span class="test-content">{JSON.stringify(arr)}</span>
24+
25+
<br />
26+
27+
{ok}
28+
<span style="word-wrap: break-word;">
29+
{JSON.stringify([...new Uint8Array(data.data_long)])}
30+
</span>
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
export const GET = () => {
2+
return new Response(new Uint8Array([1, 2, 3, 4]));
3+
};
4+
5+
export const POST = async ({ request }) => {
6+
return new Response(await request.arrayBuffer());
7+
};

packages/kit/test/apps/basics/test/test.js

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,28 @@ test.describe('Load', () => {
275275
}
276276
});
277277

278+
test('fetches using an arraybuffer serialized with b64', async ({ page, javaScriptEnabled }) => {
279+
await page.goto('/load/fetch-arraybuffer-b64');
280+
281+
expect(await page.textContent('.test-content')).toBe('[1,2,3,4]');
282+
283+
if (!javaScriptEnabled) {
284+
const payload = '{"status":200,"statusText":"","headers":{},"body":"AQIDBA=="}';
285+
const post_payload =
286+
'{"status":200,"statusText":"","headers":{},"body":"AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh8gISIjJCUmJygpKissLS4vMDEyMzQ1Njc4OTo7PD0+P0BBQkNERUZHSElKS0xNTk9QUVJTVFVWV1hZWltcXV5fYGFiY2RlZmdoaWprbG1ub3BxcnN0dXZ3eHl6e3x9fn+AgYKDhIWGh4iJiouMjY6PkJGSk5SVlpeYmZqbnJ2en6ChoqOkpaanqKmqq6ytrq+wsbKztLW2t7i5uru8vb6/wMHCw8TFxsfIycrLzM3Oz9DR0tPU1dbX2Nna29zd3t/g4eLj5OXm5+jp6uvs7e7v8PHy8/T19vf4+fr7/P3+/w=="}';
287+
288+
const script_content = await page.innerHTML(
289+
'script[data-sveltekit-fetched][data-b64][data-url="/load/fetch-arraybuffer-b64/data"]'
290+
);
291+
const post_script_content = await page.innerHTML(
292+
'script[data-sveltekit-fetched][data-b64][data-url="/load/fetch-arraybuffer-b64/data"][data-hash="16h3sp1"]'
293+
);
294+
295+
expect(script_content).toBe(payload);
296+
expect(post_script_content).toBe(post_payload);
297+
}
298+
});
299+
278300
test('json string is returned', async ({ page }) => {
279301
await page.goto('/load/relay');
280302
expect(await page.textContent('h1')).toBe('42');

0 commit comments

Comments
 (0)