Skip to content

Commit

Permalink
feat: inline Response.arrayBuffer inside load functions during ssr (#…
Browse files Browse the repository at this point in the history
…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 <hello@rich-harris.dev>
  • Loading branch information
Elia872 and Rich-Harris committed Dec 13, 2023
1 parent d7ba3bf commit 594568e
Show file tree
Hide file tree
Showing 10 changed files with 167 additions and 34 deletions.
5 changes: 5 additions & 0 deletions .changeset/fifty-tigers-turn.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@sveltejs/kit': minor
---

feat: inline `response.arrayBuffer()` during ssr
2 changes: 1 addition & 1 deletion documentation/docs/20-core-concepts/20-load.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion packages/kit/src/runtime/client/fetcher.js
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,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.
Expand All @@ -86,10 +102,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));
}
Expand Down
93 changes: 61 additions & 32 deletions packages/kit/src/runtime/server/page/load_data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<import('@sveltejs/kit').RequestEvent, 'fetch' | 'url' | 'request' | 'route'>} event
* @param {import('types').SSRState} state
Expand Down Expand Up @@ -246,38 +265,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') {
Expand All @@ -288,13 +302,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;
}
Expand Down
4 changes: 4 additions & 0 deletions packages/kit/src/runtime/server/page/serialize_data.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [];
Expand Down
1 change: 1 addition & 0 deletions packages/kit/src/runtime/server/page/types.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ export interface Fetched {
request_headers?: HeadersInit | undefined;
response_body: string;
response: Response;
is_b64?: boolean;
}

export type Loaded = {
Expand Down
Original file line number Diff line number Diff line change
@@ -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()
};
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<script>
export let data;
$: arr = [...new Uint8Array(data.data)];
let ok = 'Ok';
$: {
const p = new Uint8Array(data.data_long);
ok = p.length === 256 ? 'Ok' : 'Wrong length';
if (p.length === 256) {
for (let i = 0; i < p.length; i++) {
if (p[i] !== i) {
ok = `Expected ${i} but got ${p[i]}`;
break;
}
}
}
}
</script>

<span class="test-content">{JSON.stringify(arr)}</span>

<br />

{ok}
<span style="word-wrap: break-word;">
{JSON.stringify([...new Uint8Array(data.data_long)])}
</span>
Original file line number Diff line number Diff line change
@@ -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());
};
22 changes: 22 additions & 0 deletions packages/kit/test/apps/basics/test/test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down

0 comments on commit 594568e

Please sign in to comment.