Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: inline Response.arrayBuffer inside load functions during ssr #10535

Merged
merged 9 commits into from
Dec 13, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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;
}

Copy link
Contributor

@ximus ximus Sep 12, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Uint8Array.from could be optimized under the hood?

I think this is equivalent Uint8Array.from(atob(text), (c) => c.charCodeAt(0))

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did some testing on node v20.5.1 Ryzen 9 5950X 32GB

Uint8Array.from(atob(text), (c) => c.charCodeAt(0)) takes ~74ms to decode 1MB (out) of data

const d = atob(text);
return Uint8Array.from({length: d.length}, (_, i) => d.charCodeAt(i));

takes ~50ms

And using the for loop, ~17ms

Uint8Array.from(atob(text), (c) => c.charCodeAt(0)) is the only one with any major gc

/**
* 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 @@ -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));
}
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))
)
);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

in my own tests using kit's existing base64 worked well without needing all this. But maybe I was missing a use case.

https://github.com/sveltejs/kit/blob/master/packages/kit/src/runtime/server/page/crypto.js#L210-L239

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Node 20.6.1
In my testing kit's version is faster (~5x) for smaller sizes, at 1kib they are the same, at 32kib TextDecoder is 1.5x faster,
at 64kib TextDecoder is 2.5x and at 512kib is 10x.
for smaller sizes i don't think it matters if encoding a request takes 0.0006ms instead of 0.0001ms. But encoding 128kib is 0.5ms instead of 1.7ms


/**
* @param {Pick<import('@sveltejs/kit').RequestEvent, 'fetch' | 'url' | 'request' | 'route'>} event
* @param {import('types').SSRState} state
Expand Down Expand Up @@ -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') {
Expand All @@ -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;
}
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