diff --git a/.changeset/petite-doors-taste.md b/.changeset/petite-doors-taste.md new file mode 100644 index 000000000000..f7dbfa7de1f1 --- /dev/null +++ b/.changeset/petite-doors-taste.md @@ -0,0 +1,5 @@ +--- +'@sveltejs/kit': patch +--- + +chore: refactor redundant base64 encoding/decoding functions diff --git a/packages/kit/src/exports/index.js b/packages/kit/src/exports/index.js index 44e4b64f0ffd..8663772f0c0c 100644 --- a/packages/kit/src/exports/index.js +++ b/packages/kit/src/exports/index.js @@ -8,6 +8,7 @@ import { strip_data_suffix, strip_resolution_suffix } from '../runtime/pathname.js'; +import { text_encoder } from '../runtime/utils.js'; export { VERSION } from '../version.js'; @@ -142,7 +143,7 @@ export function json(data, init) { // means less duplicated work const headers = new Headers(init?.headers); if (!headers.has('content-length')) { - headers.set('content-length', encoder.encode(body).byteLength.toString()); + headers.set('content-length', text_encoder.encode(body).byteLength.toString()); } if (!headers.has('content-type')) { @@ -155,8 +156,6 @@ export function json(data, init) { }); } -const encoder = new TextEncoder(); - /** * Create a `Response` object from the supplied body. * @param {string} body The value that will be used as-is. @@ -165,7 +164,7 @@ const encoder = new TextEncoder(); export function text(body, init) { const headers = new Headers(init?.headers); if (!headers.has('content-length')) { - const encoded = encoder.encode(body); + const encoded = text_encoder.encode(body); headers.set('content-length', encoded.byteLength.toString()); return new Response(encoded, { ...init, diff --git a/packages/kit/src/runtime/app/server/index.js b/packages/kit/src/runtime/app/server/index.js index 3c517b8b1cde..795715bcbf1e 100644 --- a/packages/kit/src/runtime/app/server/index.js +++ b/packages/kit/src/runtime/app/server/index.js @@ -1,7 +1,7 @@ import { read_implementation, manifest } from '__sveltekit/server'; import { base } from '__sveltekit/paths'; import { DEV } from 'esm-env'; -import { b64_decode } from '../../utils.js'; +import { base64_decode } from '../../utils.js'; /** * Read the contents of an imported asset from the filesystem @@ -33,8 +33,9 @@ export function read(asset) { const data = asset.slice(match[0].length); if (match[2] !== undefined) { - const decoded = b64_decode(data); + const decoded = base64_decode(data); + // @ts-ignore passing a Uint8Array to `new Response(...)` is fine return new Response(decoded, { headers: { 'Content-Length': decoded.byteLength.toString(), diff --git a/packages/kit/src/runtime/client/client.js b/packages/kit/src/runtime/client/client.js index 0c6d4409ba4b..4486d9228215 100644 --- a/packages/kit/src/runtime/client/client.js +++ b/packages/kit/src/runtime/client/client.js @@ -44,6 +44,7 @@ import { get_message, get_status } from '../../utils/error.js'; import { writable } from 'svelte/store'; import { page, update, navigating } from './state.svelte.js'; import { add_data_suffix, add_resolution_suffix } from '../pathname.js'; +import { text_decoder } from '../utils.js'; export { load_css }; const ICON_REL_ATTRIBUTES = new Set(['icon', 'shortcut icon', 'apple-touch-icon']); @@ -2781,7 +2782,6 @@ async function load_data(url, invalid) { */ const deferreds = new Map(); const reader = /** @type {ReadableStream} */ (res.body).getReader(); - const decoder = new TextDecoder(); /** * @param {any} data @@ -2804,7 +2804,7 @@ async function load_data(url, invalid) { const { done, value } = await reader.read(); if (done && !text) break; - text += !value && text ? '\n' : decoder.decode(value, { stream: true }); // no value -> final chunk -> add a new line to trigger the last parse + text += !value && text ? '\n' : text_decoder.decode(value, { stream: true }); // no value -> final chunk -> add a new line to trigger the last parse while (true) { const split = text.indexOf('\n'); diff --git a/packages/kit/src/runtime/client/fetcher.js b/packages/kit/src/runtime/client/fetcher.js index 2213b236980f..023a1b026d85 100644 --- a/packages/kit/src/runtime/client/fetcher.js +++ b/packages/kit/src/runtime/client/fetcher.js @@ -1,6 +1,6 @@ import { BROWSER, DEV } from 'esm-env'; import { hash } from '../../utils/hash.js'; -import { b64_decode } from '../utils.js'; +import { base64_decode } from '../utils.js'; let loading = 0; @@ -98,7 +98,7 @@ export function initial_fetch(resource, opts) { if (b64 !== null) { // Can't use native_fetch('data:...;base64,${body}') // csp can block the request - body = b64_decode(body); + body = base64_decode(body); } return Promise.resolve(new Response(body, init)); diff --git a/packages/kit/src/runtime/server/cookie.js b/packages/kit/src/runtime/server/cookie.js index 2e683543a534..49da47b6eb96 100644 --- a/packages/kit/src/runtime/server/cookie.js +++ b/packages/kit/src/runtime/server/cookie.js @@ -1,6 +1,7 @@ import { parse, serialize } from 'cookie'; import { normalize_path, resolve } from '../../utils/url.js'; import { add_data_suffix } from '../pathname.js'; +import { text_encoder } from '../utils.js'; // eslint-disable-next-line no-control-regex -- control characters are invalid in cookie names const INVALID_COOKIE_CHARACTER_REGEX = /[\x00-\x1F\x7F()<>@,;:"/[\]?={} \t]/; @@ -217,7 +218,7 @@ export function get_cookies(request, url) { if (__SVELTEKIT_DEV__) { const serialized = serialize(name, value, new_cookies[name].options); - if (new TextEncoder().encode(serialized).byteLength > MAX_COOKIE_SIZE) { + if (text_encoder.encode(serialized).byteLength > MAX_COOKIE_SIZE) { throw new Error(`Cookie "${name}" is too large, and will be discarded by the browser`); } diff --git a/packages/kit/src/runtime/server/data/index.js b/packages/kit/src/runtime/server/data/index.js index 9271a064be47..b87b370bfc73 100644 --- a/packages/kit/src/runtime/server/data/index.js +++ b/packages/kit/src/runtime/server/data/index.js @@ -7,8 +7,7 @@ import { clarify_devalue_error, handle_error_and_jsonify, serialize_uses } from import { normalize_path } from '../../../utils/url.js'; import * as devalue from 'devalue'; import { create_async_iterator } from '../../../utils/streaming.js'; - -const encoder = new TextEncoder(); +import { text_encoder } from '../../utils.js'; /** * @param {import('@sveltejs/kit').RequestEvent} event @@ -129,9 +128,9 @@ export async function render_data( return new Response( new ReadableStream({ async start(controller) { - controller.enqueue(encoder.encode(data)); + controller.enqueue(text_encoder.encode(data)); for await (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)); + controller.enqueue(text_encoder.encode(chunk)); } controller.close(); }, diff --git a/packages/kit/src/runtime/server/page/crypto.js b/packages/kit/src/runtime/server/page/crypto.js index 9af02da5121a..1a08dd397e6b 100644 --- a/packages/kit/src/runtime/server/page/crypto.js +++ b/packages/kit/src/runtime/server/page/crypto.js @@ -1,4 +1,4 @@ -const encoder = new TextEncoder(); +import { text_encoder } from '../../utils.js'; /** * SHA-256 hashing function adapted from https://bitwiseshiftleft.github.io/sjcl @@ -102,7 +102,7 @@ export function sha256(data) { const bytes = new Uint8Array(out.buffer); reverse_endianness(bytes); - return base64(bytes); + return btoa(String.fromCharCode(...bytes)); } /** The SHA-256 initialization vector */ @@ -160,7 +160,7 @@ function reverse_endianness(bytes) { /** @param {string} str */ function encode(str) { - const encoded = encoder.encode(str); + const encoded = text_encoder.encode(str); const length = encoded.length * 8; // result should be a multiple of 512 bits in length, @@ -182,58 +182,3 @@ function encode(str) { return words; } - -/* - Based on https://gist.github.com/enepomnyaschih/72c423f727d395eeaa09697058238727 - - MIT License - Copyright (c) 2020 Egor Nepomnyaschih - Permission is hereby granted, free of charge, to any person obtaining a copy - of this software and associated documentation files (the "Software"), to deal - in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - copies of the Software, and to permit persons to whom the Software is - furnished to do so, subject to the following conditions: - The above copyright notice and this permission notice shall be included in all - copies or substantial portions of the Software. - THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - SOFTWARE. -*/ -const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'.split(''); - -/** @param {Uint8Array} bytes */ -export function base64(bytes) { - const l = bytes.length; - - let result = ''; - let i; - - for (i = 2; i < l; i += 3) { - result += chars[bytes[i - 2] >> 2]; - result += chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; - result += chars[((bytes[i - 1] & 0x0f) << 2) | (bytes[i] >> 6)]; - result += chars[bytes[i] & 0x3f]; - } - - if (i === l + 1) { - // 1 octet yet to write - result += chars[bytes[i - 2] >> 2]; - result += chars[(bytes[i - 2] & 0x03) << 4]; - result += '=='; - } - - if (i === l) { - // 2 octets yet to write - result += chars[bytes[i - 2] >> 2]; - result += chars[((bytes[i - 2] & 0x03) << 4) | (bytes[i - 1] >> 4)]; - result += chars[(bytes[i - 1] & 0x0f) << 2]; - result += '='; - } - - return result; -} diff --git a/packages/kit/src/runtime/server/page/crypto.spec.js b/packages/kit/src/runtime/server/page/crypto.spec.js index 5ef48af42300..90ad6f07b8a6 100644 --- a/packages/kit/src/runtime/server/page/crypto.spec.js +++ b/packages/kit/src/runtime/server/page/crypto.spec.js @@ -1,6 +1,7 @@ import { webcrypto } from 'node:crypto'; import { assert, test } from 'vitest'; import { sha256 } from './crypto.js'; +import { text_encoder } from '../../utils.js'; const inputs = [ 'hello world', @@ -8,14 +9,11 @@ const inputs = [ 'abcd', 'the quick brown fox jumps over the lazy dog', '工欲善其事,必先利其器' -].slice(0); +]; inputs.forEach((input) => { test(input, async () => { - const expected_bytes = await webcrypto.subtle.digest( - 'SHA-256', - new TextEncoder().encode(input) - ); + const expected_bytes = await webcrypto.subtle.digest('SHA-256', text_encoder.encode(input)); const expected = Buffer.from(expected_bytes).toString('base64'); const actual = sha256(input); diff --git a/packages/kit/src/runtime/server/page/csp.js b/packages/kit/src/runtime/server/page/csp.js index 1376235b45de..074a222c3ca5 100644 --- a/packages/kit/src/runtime/server/page/csp.js +++ b/packages/kit/src/runtime/server/page/csp.js @@ -1,11 +1,11 @@ import { escape_html } from '../../../utils/escape.js'; -import { base64, sha256 } from './crypto.js'; +import { sha256 } from './crypto.js'; const array = new Uint8Array(16); function generate_nonce() { crypto.getRandomValues(array); - return base64(array); + return btoa(String.fromCharCode(...array)); } const quoted = new Set([ diff --git a/packages/kit/src/runtime/server/page/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index 373a285bca03..72a77b5aceb6 100644 --- a/packages/kit/src/runtime/server/page/load_data.js +++ b/packages/kit/src/runtime/server/page/load_data.js @@ -1,7 +1,7 @@ import { DEV } from 'esm-env'; import { disable_search, make_trackable } from '../../../utils/url.js'; import { validate_depends } from '../../shared.js'; -import { b64_encode } from '../../utils.js'; +import { base64_encode, text_decoder } from '../../utils.js'; import { with_event } from '../../app/server/event.js'; /** @@ -316,12 +316,14 @@ export function create_universal_fetch(event, state, fetched, csr, resolve_opts) return async () => { const buffer = await response.arrayBuffer(); + const bytes = new Uint8Array(buffer); + if (dependency) { - dependency.body = new Uint8Array(buffer); + dependency.body = bytes; } if (buffer instanceof ArrayBuffer) { - await push_fetched(b64_encode(buffer), true); + await push_fetched(base64_encode(bytes), true); } return buffer; @@ -394,13 +396,12 @@ export function create_universal_fetch(event, state, fetched, csr, resolve_opts) async function stream_to_string(stream) { let result = ''; const reader = stream.getReader(); - const decoder = new TextDecoder(); while (true) { const { done, value } = await reader.read(); if (done) { break; } - result += decoder.decode(value); + result += text_decoder.decode(value); } return result; } diff --git a/packages/kit/src/runtime/server/page/render.js b/packages/kit/src/runtime/server/page/render.js index 50e1ad057ae5..cdff4095788e 100644 --- a/packages/kit/src/runtime/server/page/render.js +++ b/packages/kit/src/runtime/server/page/render.js @@ -17,6 +17,7 @@ import { create_server_routing_response, generate_route_object } from './server_ import { add_resolution_suffix } from '../../pathname.js'; import { with_event } from '../../app/server/event.js'; import { get_event_state } from '../event-state.js'; +import { text_encoder } from '../../utils.js'; // TODO rename this function/module @@ -25,8 +26,6 @@ const updated = { check: () => false }; -const encoder = new TextEncoder(); - /** * Creates the HTML response. * @param {{ @@ -586,9 +585,9 @@ export async function render_response({ : new Response( new ReadableStream({ async start(controller) { - controller.enqueue(encoder.encode(transformed + '\n')); + controller.enqueue(text_encoder.encode(transformed + '\n')); for await (const chunk of chunks) { - controller.enqueue(encoder.encode(chunk)); + controller.enqueue(text_encoder.encode(chunk)); } controller.close(); }, diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 58021176dfad..edfdc6916986 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -1,5 +1,6 @@ /** @import { Transport } from '@sveltejs/kit' */ import * as devalue from 'devalue'; +import { base64_decode, base64_encode, text_decoder } from './utils.js'; /** * @param {string} route_id @@ -41,12 +42,8 @@ export function stringify_remote_arg(value, transport) { // If people hit file/url size limits, we can look into using something like compress_and_encode_text from svelte.dev beyond a certain size const json_string = stringify(value, transport); - // Convert to UTF-8 bytes, then base64 - handles all Unicode properly (btoa would fail on exotic characters) - const utf8_bytes = new TextEncoder().encode(json_string); - return btoa(String.fromCharCode(...utf8_bytes)) - .replace(/=/g, '') - .replace(/\+/g, '-') - .replace(/\//g, '_'); + const bytes = new TextEncoder().encode(json_string); + return base64_encode(bytes).replaceAll('=', '').replaceAll('+', '-').replaceAll('/', '_'); } /** @@ -57,13 +54,12 @@ export function stringify_remote_arg(value, transport) { export function parse_remote_arg(string, transport) { if (!string) return undefined; - const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode])); + const json_string = text_decoder.decode( + // no need to add back `=` characters, atob can handle it + base64_decode(string.replaceAll('-', '+').replaceAll('_', '/')) + ); - // We don't need to add back the `=`-padding because atob can handle it - const base64_restored = string.replace(/-/g, '+').replace(/_/g, '/'); - const binary_string = atob(base64_restored); - const utf8_bytes = new Uint8Array([...binary_string].map((char) => char.charCodeAt(0))); - const json_string = new TextDecoder().decode(utf8_bytes); + const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode])); return devalue.parse(json_string, decoders); } diff --git a/packages/kit/src/runtime/utils.js b/packages/kit/src/runtime/utils.js index 2da4498d9b7f..b6e3103ff1ee 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -1,37 +1,7 @@ -/** - * @param {string} text - * @returns {ArrayBufferLike} - */ -export function b64_decode(text) { - const d = atob(text); +import { BROWSER } from 'esm-env'; - const u8 = new Uint8Array(d.length); - - for (let i = 0; i < d.length; i++) { - u8[i] = d.charCodeAt(i); - } - - return u8.buffer; -} - -/** - * @param {ArrayBuffer} buffer - * @returns {string} - */ -export 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)) - ) - ); -} +export const text_encoder = new TextEncoder(); +export const text_decoder = new TextDecoder(); /** * Like node's path.relative, but without using node @@ -53,3 +23,43 @@ export function get_relative_path(from, to) { return from_parts.concat(to_parts).join('/'); } + +/** + * @param {Uint8Array} bytes + * @returns {string} + */ +export function base64_encode(bytes) { + // Using `Buffer` is faster than iterating + if (!BROWSER && globalThis.Buffer) { + return globalThis.Buffer.from(bytes).toString('base64'); + } + + let binary = ''; + + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + + return btoa(binary); +} + +/** + * @param {string} encoded + * @returns {Uint8Array} + */ +export function base64_decode(encoded) { + // Using `Buffer` is faster than iterating + if (!BROWSER && globalThis.Buffer) { + const buffer = globalThis.Buffer.from(encoded, 'base64'); + return new Uint8Array(buffer); + } + + const binary = atob(encoded); + const bytes = new Uint8Array(binary.length); + + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + + return bytes; +} diff --git a/packages/kit/src/runtime/utils.spec.js b/packages/kit/src/runtime/utils.spec.js new file mode 100644 index 000000000000..673ba0b0bb33 --- /dev/null +++ b/packages/kit/src/runtime/utils.spec.js @@ -0,0 +1,37 @@ +import { afterEach, assert, beforeEach, describe, expect, test } from 'vitest'; +import { base64_decode, base64_encode, text_encoder } from './utils.js'; + +const inputs = [ + 'hello world', + '', + 'abcd', + 'the quick brown fox jumps over the lazy dog', + '工欲善其事,必先利其器' +]; + +const buffer = globalThis.Buffer; +beforeEach(() => { + // @ts-expect-error + delete globalThis.Buffer; +}); +afterEach(() => { + globalThis.Buffer = buffer; +}); + +describe('base64_encode', () => { + test.each(inputs)('%s', (input) => { + const expected = buffer.from(input).toString('base64'); + + const actual = base64_encode(text_encoder.encode(input)); + assert.equal(actual, expected); + }); +}); + +describe('base64_decode', () => { + test.each(inputs)('%s', (input) => { + const encoded = buffer.from(input).toString('base64'); + + const actual = base64_decode(encoded); + expect(actual).toEqual(text_encoder.encode(input)); + }); +}); diff --git a/packages/kit/test/apps/basics/test/client.test.js b/packages/kit/test/apps/basics/test/client.test.js index 1c4db540aef4..25e5134193bd 100644 --- a/packages/kit/test/apps/basics/test/client.test.js +++ b/packages/kit/test/apps/basics/test/client.test.js @@ -42,6 +42,7 @@ test.describe('Endpoints', () => { test.describe('Load', () => { test('load function is only called when necessary', async ({ app, page }) => { + test.slow(); await page.goto('/load/change-detection/one/a'); expect(await page.textContent('h1')).toBe('layout loads: 1'); expect(await page.textContent('h2')).toBe('x: a: 1');