From 5c3690f5e75cb35a2560396f7e916523831d623d Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 19:27:12 -0700 Subject: [PATCH 1/8] chore: refactor redundant base64 encoding/decoding --- packages/kit/src/runtime/app/server/index.js | 4 +- packages/kit/src/runtime/client/fetcher.js | 4 +- .../kit/src/runtime/server/page/crypto.js | 59 +---- packages/kit/src/runtime/server/page/csp.js | 5 +- .../kit/src/runtime/server/page/load_data.js | 8 +- packages/kit/src/runtime/shared.js | 20 +- packages/kit/src/runtime/utils.js | 233 +++++++++++++++--- 7 files changed, 221 insertions(+), 112 deletions(-) diff --git a/packages/kit/src/runtime/app/server/index.js b/packages/kit/src/runtime/app/server/index.js index 3c517b8b1cde..c6a93f34f92a 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,7 +33,7 @@ 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); return new Response(decoded, { headers: { 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/page/crypto.js b/packages/kit/src/runtime/server/page/crypto.js index 9af02da5121a..61af262ddfe1 100644 --- a/packages/kit/src/runtime/server/page/crypto.js +++ b/packages/kit/src/runtime/server/page/crypto.js @@ -1,3 +1,5 @@ +import { base64_encode } from '../../utils.js'; + const encoder = new TextEncoder(); /** @@ -102,7 +104,7 @@ export function sha256(data) { const bytes = new Uint8Array(out.buffer); reverse_endianness(bytes); - return base64(bytes); + return base64_encode(bytes); } /** The SHA-256 initialization vector */ @@ -182,58 +184,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/csp.js b/packages/kit/src/runtime/server/page/csp.js index 1376235b45de..7778ade4e0ff 100644 --- a/packages/kit/src/runtime/server/page/csp.js +++ b/packages/kit/src/runtime/server/page/csp.js @@ -1,11 +1,12 @@ import { escape_html } from '../../../utils/escape.js'; -import { base64, sha256 } from './crypto.js'; +import { base64_encode } from '../../utils.js'; +import { sha256 } from './crypto.js'; const array = new Uint8Array(16); function generate_nonce() { crypto.getRandomValues(array); - return base64(array); + return base64_encode(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..abd5fd8b1f1e 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 } 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; diff --git a/packages/kit/src/runtime/shared.js b/packages/kit/src/runtime/shared.js index 58021176dfad..508f127d6d36 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 } from './utils.js'; /** * @param {string} route_id @@ -41,12 +42,10 @@ 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, '_'); + return base64_encode(new TextEncoder().encode(json_string), { + alphabet: 'base64url', + omitPadding: true + }); } /** @@ -57,14 +56,11 @@ 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])); - - // 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 utf8_bytes = base64_decode(string, { alphabet: 'base64url' }); 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..5f8df384130b 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -1,38 +1,3 @@ -/** - * @param {string} text - * @returns {ArrayBufferLike} - */ -export 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; -} - -/** - * @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)) - ) - ); -} - /** * Like node's path.relative, but without using node * @param {string} from @@ -53,3 +18,201 @@ export function get_relative_path(from, to) { return from_parts.concat(to_parts).join('/'); } + +const native_b64_supported = 'fromBase64' in Uint8Array; + +/** + * @param {string} encoded + * @param {{ alphabet?: 'base64' | 'base64url' }=} options + * @returns {Uint8Array} + */ +export function base64_decode(encoded, options) { + if (native_b64_supported) { + // @ts-expect-error - https://github.com/microsoft/TypeScript/pull/61696 + return Uint8Array.fromBase64(encoded, options); + } + + const decode_map = options?.alphabet === 'base64url' ? b64_url_decode_map : b64_decode_map; + + const result = new Uint8Array(Math.ceil(encoded.length / 4) * 3); + let total_bytes = 0; + for (let i = 0; i < encoded.length; i += 4) { + let chunk = 0; + let bits_read = 0; + for (let j = 0; j < 4; j++) { + const char = encoded[i + j]; + // if (padding === DecodingPadding.Required && encoded[i + j] === "=") { + // continue; + // } + if ( + // padding === DecodingPadding.Ignore && + i + j >= encoded.length || + char === '=' + ) { + continue; + } + if (j > 0 && encoded[i + j - 1] === '=') { + throw new Error('Invalid padding'); + } + if (!(char in decode_map)) { + throw new Error('Invalid character'); + } + chunk |= decode_map[/** @type {keyof typeof decode_map} */ (char)] << ((3 - j) * 6); + bits_read += 6; + } + if (bits_read < 24) { + /** @type {number} */ + let unused; + if (bits_read === 12) { + unused = chunk & 0xffff; + } else if (bits_read === 18) { + unused = chunk & 0xff; + } else { + throw new Error('Invalid padding'); + } + if (unused !== 0) { + throw new Error('Invalid padding'); + } + } + const byte_length = Math.floor(bits_read / 8); + for (let i = 0; i < byte_length; i++) { + result[total_bytes] = (chunk >> (16 - i * 8)) & 0xff; + total_bytes++; + } + } + return result.slice(0, total_bytes); +} + +/** + * @param {Uint8Array} bytes + * @param {{ alphabet?: 'base64' | 'base64url', omitPadding?: boolean }=} options + * @returns {string} + */ +export function base64_encode(bytes, options) { + if (native_b64_supported) { + // @ts-expect-error - https://github.com/microsoft/TypeScript/pull/61696 + return bytes.toBase64(options); + } + + const alphabet = options?.alphabet === 'base64url' ? b64_url_alphabet : b64_alphabet; + const omit_padding = options?.omitPadding ?? false; + + let result = ''; + for (let i = 0; i < bytes.byteLength; i += 3) { + let buffer = 0; + let buffer_bit_size = 0; + for (let j = 0; j < 3 && i + j < bytes.byteLength; j++) { + buffer = (buffer << 8) | bytes[i + j]; + buffer_bit_size += 8; + } + for (let j = 0; j < 4; j++) { + if (buffer_bit_size >= 6) { + result += alphabet[(buffer >> (buffer_bit_size - 6)) & 0x3f]; + buffer_bit_size -= 6; + } else if (buffer_bit_size > 0) { + result += alphabet[(buffer << (6 - buffer_bit_size)) & 0x3f]; + buffer_bit_size = 0; + } else if (!omit_padding) { + result += '='; + } + } + } + return result; +} + +const b64_alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'; +const b64_url_alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'; + +const b64_decode_map = { + 0: 52, + 1: 53, + 2: 54, + 3: 55, + 4: 56, + 5: 57, + 6: 58, + 7: 59, + 8: 60, + 9: 61, + A: 0, + B: 1, + C: 2, + D: 3, + E: 4, + F: 5, + G: 6, + H: 7, + I: 8, + J: 9, + K: 10, + L: 11, + M: 12, + N: 13, + O: 14, + P: 15, + Q: 16, + R: 17, + S: 18, + T: 19, + U: 20, + V: 21, + W: 22, + X: 23, + Y: 24, + Z: 25, + a: 26, + b: 27, + c: 28, + d: 29, + e: 30, + f: 31, + g: 32, + h: 33, + i: 34, + j: 35, + k: 36, + l: 37, + m: 38, + n: 39, + o: 40, + p: 41, + q: 42, + r: 43, + s: 44, + t: 45, + u: 46, + v: 47, + w: 48, + x: 49, + y: 50, + z: 51, + '+': 62, + '/': 63 +}; + +const b64_url_decode_map = { ...b64_decode_map, '-': 62, _: 63 }; + +/** + Base64 functions based on https://github.com/oslo-project/encoding/blob/main/src/base64.ts + + MIT License + Copyright (c) 2024 pilcrowOnPaper + + 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. + */ From 66400274f60c0defd73ff78067bbe031063d664f Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 19:29:30 -0700 Subject: [PATCH 2/8] changeset --- .changeset/petite-doors-taste.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/petite-doors-taste.md 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 From 766ff57f0951d48988a0f935bd9241aed9845180 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 19:32:55 -0700 Subject: [PATCH 3/8] mark slow test --- packages/kit/test/apps/basics/test/client.test.js | 1 + 1 file changed, 1 insertion(+) 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'); From af06847e666cec2a8b0e082b3d0198d0591dc5d5 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 20:33:35 -0700 Subject: [PATCH 4/8] add test, add buffer fallback --- .../src/runtime/server/page/crypto.spec.js | 2 +- packages/kit/src/runtime/utils.js | 24 ++++--- packages/kit/src/runtime/utils.spec.js | 64 +++++++++++++++++++ 3 files changed, 80 insertions(+), 10 deletions(-) create mode 100644 packages/kit/src/runtime/utils.spec.js diff --git a/packages/kit/src/runtime/server/page/crypto.spec.js b/packages/kit/src/runtime/server/page/crypto.spec.js index 5ef48af42300..693b87d6a15a 100644 --- a/packages/kit/src/runtime/server/page/crypto.spec.js +++ b/packages/kit/src/runtime/server/page/crypto.spec.js @@ -8,7 +8,7 @@ const inputs = [ 'abcd', 'the quick brown fox jumps over the lazy dog', '工欲善其事,必先利其器' -].slice(0); +]; inputs.forEach((input) => { test(input, async () => { diff --git a/packages/kit/src/runtime/utils.js b/packages/kit/src/runtime/utils.js index 5f8df384130b..46879b5d999e 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -20,17 +20,22 @@ export function get_relative_path(from, to) { } const native_b64_supported = 'fromBase64' in Uint8Array; +const node_b64_supported = 'Buffer' in globalThis; /** * @param {string} encoded * @param {{ alphabet?: 'base64' | 'base64url' }=} options - * @returns {Uint8Array} + * @returns {Uint8Array} */ export function base64_decode(encoded, options) { if (native_b64_supported) { // @ts-expect-error - https://github.com/microsoft/TypeScript/pull/61696 return Uint8Array.fromBase64(encoded, options); } + if (node_b64_supported) { + const buffer = Buffer.from(encoded, options?.alphabet === 'base64url' ? 'base64url' : 'base64'); + return new Uint8Array(buffer); + } const decode_map = options?.alphabet === 'base64url' ? b64_url_decode_map : b64_decode_map; @@ -41,14 +46,7 @@ export function base64_decode(encoded, options) { let bits_read = 0; for (let j = 0; j < 4; j++) { const char = encoded[i + j]; - // if (padding === DecodingPadding.Required && encoded[i + j] === "=") { - // continue; - // } - if ( - // padding === DecodingPadding.Ignore && - i + j >= encoded.length || - char === '=' - ) { + if (i + j >= encoded.length || char === '=') { continue; } if (j > 0 && encoded[i + j - 1] === '=') { @@ -93,6 +91,14 @@ export function base64_encode(bytes, options) { // @ts-expect-error - https://github.com/microsoft/TypeScript/pull/61696 return bytes.toBase64(options); } + if (node_b64_supported) { + const buffer = Buffer.from(bytes.buffer); + const encoded = buffer.toString(options?.alphabet === 'base64url' ? 'base64url' : 'base64'); + if (options?.omitPadding) { + return encoded.replace(/=+$/, ''); + } + return encoded; + } const alphabet = options?.alphabet === 'base64url' ? b64_url_alphabet : b64_alphabet; const omit_padding = options?.omitPadding ?? false; diff --git a/packages/kit/src/runtime/utils.spec.js b/packages/kit/src/runtime/utils.spec.js new file mode 100644 index 000000000000..9fa6041de011 --- /dev/null +++ b/packages/kit/src/runtime/utils.spec.js @@ -0,0 +1,64 @@ +import { assert, describe, expect, test } from 'vitest'; + +// Hack to pretend Buffer isn't available, to test the fallback implementation + +// @ts-expect-error +const _buffer = globalThis.Buffer; +delete globalThis.Buffer; +// eslint-disable-next-line @typescript-eslint/no-require-imports +const { base64_decode, base64_encode } = require('./utils.js'); +// @ts-expect-error +globalThis.Buffer = _buffer; + +const inputs = [ + 'hello world', + '', + 'abcd', + 'the quick brown fox jumps over the lazy dog', + '工欲善其事,必先利其器' +]; + +const text_encoder = new TextEncoder(); + +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); + }); + + test.each(inputs)('(omitPadding) %s', (input) => { + const expected = Buffer.from(input).toString('base64').replace(/=+$/, ''); + + const actual = base64_encode(text_encoder.encode(input), { omitPadding: true }); + assert.equal(actual, expected); + }); + + test.each(inputs)('(url) %s', (input) => { + const expected = Buffer.from(input).toString('base64url'); + + const actual = base64_encode(text_encoder.encode(input), { + alphabet: 'base64url', + omitPadding: true + }); + 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)); + }); + + test.each(inputs)('(url) %s', (input) => { + const encoded = Buffer.from(input).toString('base64url'); + + const actual = base64_decode(encoded, { alphabet: 'base64url' }); + + expect(actual).toEqual(text_encoder.encode(input)); + }); +}); From d0eda6ddadc38129635d416d8630702c64e218ff Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 21:55:43 -0700 Subject: [PATCH 5/8] fix test --- packages/kit/src/runtime/utils.spec.js | 22 ++++++++++++++-------- 1 file changed, 14 insertions(+), 8 deletions(-) diff --git a/packages/kit/src/runtime/utils.spec.js b/packages/kit/src/runtime/utils.spec.js index 9fa6041de011..e02f3c62948a 100644 --- a/packages/kit/src/runtime/utils.spec.js +++ b/packages/kit/src/runtime/utils.spec.js @@ -1,14 +1,20 @@ import { assert, describe, expect, test } from 'vitest'; -// Hack to pretend Buffer isn't available, to test the fallback implementation +/** @type {typeof import('./utils.js')} */ +let module; -// @ts-expect-error -const _buffer = globalThis.Buffer; -delete globalThis.Buffer; -// eslint-disable-next-line @typescript-eslint/no-require-imports -const { base64_decode, base64_encode } = require('./utils.js'); -// @ts-expect-error -globalThis.Buffer = _buffer; +// Hack to pretend Buffer isn't available, to test the fallback implementation +if ('Buffer' in globalThis) { + const _buffer = globalThis.Buffer; + // @ts-expect-error + delete globalThis.Buffer; + // eslint-disable-next-line @typescript-eslint/no-require-imports + module = require('./utils.js'); + globalThis.Buffer = _buffer; +} else { + module = await import('./utils.js'); +} +const { base64_decode, base64_encode } = module; const inputs = [ 'hello world', From 642b6886ade485c1c6e856a690a46034833bd5b7 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 22:05:07 -0700 Subject: [PATCH 6/8] check Buffer on each run --- packages/kit/src/runtime/utils.js | 5 ++-- packages/kit/src/runtime/utils.spec.js | 38 +++++++++++--------------- 2 files changed, 18 insertions(+), 25 deletions(-) diff --git a/packages/kit/src/runtime/utils.js b/packages/kit/src/runtime/utils.js index 46879b5d999e..d4da72b6cb19 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -20,7 +20,6 @@ export function get_relative_path(from, to) { } const native_b64_supported = 'fromBase64' in Uint8Array; -const node_b64_supported = 'Buffer' in globalThis; /** * @param {string} encoded @@ -32,7 +31,7 @@ export function base64_decode(encoded, options) { // @ts-expect-error - https://github.com/microsoft/TypeScript/pull/61696 return Uint8Array.fromBase64(encoded, options); } - if (node_b64_supported) { + if ('Buffer' in globalThis) { const buffer = Buffer.from(encoded, options?.alphabet === 'base64url' ? 'base64url' : 'base64'); return new Uint8Array(buffer); } @@ -91,7 +90,7 @@ export function base64_encode(bytes, options) { // @ts-expect-error - https://github.com/microsoft/TypeScript/pull/61696 return bytes.toBase64(options); } - if (node_b64_supported) { + if ('Buffer' in globalThis) { const buffer = Buffer.from(bytes.buffer); const encoded = buffer.toString(options?.alphabet === 'base64url' ? 'base64url' : 'base64'); if (options?.omitPadding) { diff --git a/packages/kit/src/runtime/utils.spec.js b/packages/kit/src/runtime/utils.spec.js index e02f3c62948a..cf3234a680a6 100644 --- a/packages/kit/src/runtime/utils.spec.js +++ b/packages/kit/src/runtime/utils.spec.js @@ -1,20 +1,5 @@ -import { assert, describe, expect, test } from 'vitest'; - -/** @type {typeof import('./utils.js')} */ -let module; - -// Hack to pretend Buffer isn't available, to test the fallback implementation -if ('Buffer' in globalThis) { - const _buffer = globalThis.Buffer; - // @ts-expect-error - delete globalThis.Buffer; - // eslint-disable-next-line @typescript-eslint/no-require-imports - module = require('./utils.js'); - globalThis.Buffer = _buffer; -} else { - module = await import('./utils.js'); -} -const { base64_decode, base64_encode } = module; +import { afterEach, assert, beforeEach, describe, expect, test } from 'vitest'; +import { base64_decode, base64_encode } from './utils.js'; const inputs = [ 'hello world', @@ -26,23 +11,32 @@ const inputs = [ const text_encoder = new TextEncoder(); +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 expected = buffer.from(input).toString('base64'); const actual = base64_encode(text_encoder.encode(input)); assert.equal(actual, expected); }); test.each(inputs)('(omitPadding) %s', (input) => { - const expected = Buffer.from(input).toString('base64').replace(/=+$/, ''); + const expected = buffer.from(input).toString('base64').replace(/=+$/, ''); const actual = base64_encode(text_encoder.encode(input), { omitPadding: true }); assert.equal(actual, expected); }); test.each(inputs)('(url) %s', (input) => { - const expected = Buffer.from(input).toString('base64url'); + const expected = buffer.from(input).toString('base64url'); const actual = base64_encode(text_encoder.encode(input), { alphabet: 'base64url', @@ -54,14 +48,14 @@ describe('base64_encode', () => { describe('base64_decode', () => { test.each(inputs)('%s', (input) => { - const encoded = Buffer.from(input).toString('base64'); + const encoded = buffer.from(input).toString('base64'); const actual = base64_decode(encoded); expect(actual).toEqual(text_encoder.encode(input)); }); test.each(inputs)('(url) %s', (input) => { - const encoded = Buffer.from(input).toString('base64url'); + const encoded = buffer.from(input).toString('base64url'); const actual = base64_decode(encoded, { alphabet: 'base64url' }); From 72d11ded04c2d59250c8e784912dbfe4ca49b717 Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 22:19:07 -0700 Subject: [PATCH 7/8] probably check this every run too --- packages/kit/src/runtime/utils.js | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/kit/src/runtime/utils.js b/packages/kit/src/runtime/utils.js index d4da72b6cb19..2c02f3e5f84c 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -19,15 +19,13 @@ export function get_relative_path(from, to) { return from_parts.concat(to_parts).join('/'); } -const native_b64_supported = 'fromBase64' in Uint8Array; - /** * @param {string} encoded * @param {{ alphabet?: 'base64' | 'base64url' }=} options * @returns {Uint8Array} */ export function base64_decode(encoded, options) { - if (native_b64_supported) { + if ('fromBase64' in Uint8Array) { // @ts-expect-error - https://github.com/microsoft/TypeScript/pull/61696 return Uint8Array.fromBase64(encoded, options); } @@ -86,7 +84,7 @@ export function base64_decode(encoded, options) { * @returns {string} */ export function base64_encode(bytes, options) { - if (native_b64_supported) { + if ('toBase64' in Uint8Array.prototype) { // @ts-expect-error - https://github.com/microsoft/TypeScript/pull/61696 return bytes.toBase64(options); } From 731347f60837fd4b749a18e14ca8e886a8a8c71a Mon Sep 17 00:00:00 2001 From: Ottomated Date: Fri, 8 Aug 2025 22:36:36 -0700 Subject: [PATCH 8/8] singleton textencoder / textdecoder --- packages/kit/src/exports/index.js | 7 +++---- packages/kit/src/runtime/client/client.js | 4 ++-- packages/kit/src/runtime/server/cookie.js | 3 ++- packages/kit/src/runtime/server/data/index.js | 7 +++---- packages/kit/src/runtime/server/page/crypto.js | 6 ++---- packages/kit/src/runtime/server/page/crypto.spec.js | 6 ++---- packages/kit/src/runtime/server/page/load_data.js | 5 ++--- packages/kit/src/runtime/server/page/render.js | 7 +++---- packages/kit/src/runtime/shared.js | 7 +++---- packages/kit/src/runtime/utils.js | 3 +++ packages/kit/src/runtime/utils.spec.js | 4 +--- 11 files changed, 26 insertions(+), 33 deletions(-) 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/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/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 61af262ddfe1..f7a4ab71ae9b 100644 --- a/packages/kit/src/runtime/server/page/crypto.js +++ b/packages/kit/src/runtime/server/page/crypto.js @@ -1,6 +1,4 @@ -import { base64_encode } from '../../utils.js'; - -const encoder = new TextEncoder(); +import { base64_encode, text_encoder } from '../../utils.js'; /** * SHA-256 hashing function adapted from https://bitwiseshiftleft.github.io/sjcl @@ -162,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, diff --git a/packages/kit/src/runtime/server/page/crypto.spec.js b/packages/kit/src/runtime/server/page/crypto.spec.js index 693b87d6a15a..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', @@ -12,10 +13,7 @@ const inputs = [ 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/load_data.js b/packages/kit/src/runtime/server/page/load_data.js index abd5fd8b1f1e..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 { base64_encode } from '../../utils.js'; +import { base64_encode, text_decoder } from '../../utils.js'; import { with_event } from '../../app/server/event.js'; /** @@ -396,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 508f127d6d36..a004f93af3b6 100644 --- a/packages/kit/src/runtime/shared.js +++ b/packages/kit/src/runtime/shared.js @@ -1,6 +1,6 @@ /** @import { Transport } from '@sveltejs/kit' */ import * as devalue from 'devalue'; -import { base64_decode, base64_encode } from './utils.js'; +import { base64_decode, base64_encode, text_decoder, text_encoder } from './utils.js'; /** * @param {string} route_id @@ -42,7 +42,7 @@ 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); - return base64_encode(new TextEncoder().encode(json_string), { + return base64_encode(text_encoder.encode(json_string), { alphabet: 'base64url', omitPadding: true }); @@ -56,8 +56,7 @@ export function stringify_remote_arg(value, transport) { export function parse_remote_arg(string, transport) { if (!string) return undefined; - const utf8_bytes = base64_decode(string, { alphabet: 'base64url' }); - const json_string = new TextDecoder().decode(utf8_bytes); + const json_string = text_decoder.decode(base64_decode(string, { alphabet: 'base64url' })); const decoders = Object.fromEntries(Object.entries(transport).map(([k, v]) => [k, v.decode])); diff --git a/packages/kit/src/runtime/utils.js b/packages/kit/src/runtime/utils.js index 2c02f3e5f84c..f182bea2c927 100644 --- a/packages/kit/src/runtime/utils.js +++ b/packages/kit/src/runtime/utils.js @@ -1,3 +1,6 @@ +export const text_encoder = new TextEncoder(); +export const text_decoder = new TextDecoder(); + /** * Like node's path.relative, but without using node * @param {string} from diff --git a/packages/kit/src/runtime/utils.spec.js b/packages/kit/src/runtime/utils.spec.js index cf3234a680a6..a093ef30bebc 100644 --- a/packages/kit/src/runtime/utils.spec.js +++ b/packages/kit/src/runtime/utils.spec.js @@ -1,5 +1,5 @@ import { afterEach, assert, beforeEach, describe, expect, test } from 'vitest'; -import { base64_decode, base64_encode } from './utils.js'; +import { base64_decode, base64_encode, text_encoder } from './utils.js'; const inputs = [ 'hello world', @@ -9,8 +9,6 @@ const inputs = [ '工欲善其事,必先利其器' ]; -const text_encoder = new TextEncoder(); - const buffer = globalThis.Buffer; beforeEach(() => { // @ts-expect-error