diff --git a/.changeset/brown-carrots-shave.md b/.changeset/brown-carrots-shave.md new file mode 100644 index 0000000..9f15b79 --- /dev/null +++ b/.changeset/brown-carrots-shave.md @@ -0,0 +1,5 @@ +--- +"devalue": minor +--- + +feat: use native alternatives to encode/decode base64 diff --git a/.changeset/happy-mugs-happen.md b/.changeset/happy-mugs-happen.md new file mode 100644 index 0000000..c6bc805 --- /dev/null +++ b/.changeset/happy-mugs-happen.md @@ -0,0 +1,5 @@ +--- +"devalue": minor +--- + +feat: simplify TypedArray slices diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2edff8f..6d3379d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -18,7 +18,7 @@ jobs: strategy: fail-fast: false matrix: - node-version: [16, 18, 20] + node-version: [16, 18, 20, 22, 24, 25] os: [ubuntu-latest] steps: - run: git config --global core.autocrlf false diff --git a/src/base64.js b/src/base64.js index 23bd6b6..0fd1fcc 100644 --- a/src/base64.js +++ b/src/base64.js @@ -1,110 +1,69 @@ -/** - * Base64 Encodes an arraybuffer - * @param {ArrayBuffer} arraybuffer - * @returns {string} - */ -export function encode64(arraybuffer) { - const dv = new DataView(arraybuffer); - let binaryString = ""; +/* Baseline 2025 runtimes */ - for (let i = 0; i < arraybuffer.byteLength; i++) { - binaryString += String.fromCharCode(dv.getUint8(i)); - } +/** @type {(array_buffer: ArrayBuffer) => string} */ +export function encode_native(array_buffer) { + return new Uint8Array(array_buffer).toBase64(); +} - return binaryToAscii(binaryString); +/** @type {(base64: string) => ArrayBuffer} */ +export function decode_native(base64) { + return Uint8Array.fromBase64(base64).buffer; } -/** - * Decodes a base64 string into an arraybuffer - * @param {string} string - * @returns {ArrayBuffer} - */ -export function decode64(string) { - const binaryString = asciiToBinary(string); - const arraybuffer = new ArrayBuffer(binaryString.length); - const dv = new DataView(arraybuffer); +/* Node-compatible runtimes */ - for (let i = 0; i < arraybuffer.byteLength; i++) { - dv.setUint8(i, binaryString.charCodeAt(i)); - } +/** @type {(array_buffer: ArrayBuffer) => string} */ +export function encode_buffer(array_buffer) { + return Buffer.from(array_buffer).toString('base64'); +} - return arraybuffer; +/** @type {(base64: string) => ArrayBuffer} */ +export function decode_buffer(base64) { + return Uint8Array.from(Buffer.from(base64, 'base64')).buffer; } -const KEY_STRING = - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; +/* Legacy runtimes */ -/** - * Substitute for atob since it's deprecated in node. - * Does not do any input validation. - * - * @see https://github.com/jsdom/abab/blob/master/lib/atob.js - * - * @param {string} data - * @returns {string} - */ -function asciiToBinary(data) { - if (data.length % 4 === 0) { - data = data.replace(/==?$/, ""); - } +/** @type {(array_buffer: ArrayBuffer) => string} */ +export function encode_legacy(array_buffer) { + const array = new Uint8Array(array_buffer); + let binary = ''; - let output = ""; - let buffer = 0; - let accumulatedBits = 0; + // the maximum number of arguments to String.fromCharCode.apply + // should be around 0xFFFF in modern engines + const chunk_size = 0x8000; + for (let i = 0; i < array.length; i += chunk_size) { + const chunk = array.subarray(i, i + chunk_size); + binary += String.fromCharCode.apply(null, chunk); + } - for (let i = 0; i < data.length; i++) { - buffer <<= 6; - buffer |= KEY_STRING.indexOf(data[i]); - accumulatedBits += 6; - if (accumulatedBits === 24) { - output += String.fromCharCode((buffer & 0xff0000) >> 16); - output += String.fromCharCode((buffer & 0xff00) >> 8); - output += String.fromCharCode(buffer & 0xff); - buffer = accumulatedBits = 0; - } - } - if (accumulatedBits === 12) { - buffer >>= 4; - output += String.fromCharCode(buffer); - } else if (accumulatedBits === 18) { - buffer >>= 2; - output += String.fromCharCode((buffer & 0xff00) >> 8); - output += String.fromCharCode(buffer & 0xff); - } - return output; + return btoa(binary); } -/** - * Substitute for btoa since it's deprecated in node. - * Does not do any input validation. - * - * @see https://github.com/jsdom/abab/blob/master/lib/btoa.js - * - * @param {string} str - * @returns {string} - */ -function binaryToAscii(str) { - let out = ""; - for (let i = 0; i < str.length; i += 3) { - /** @type {[number, number, number, number]} */ - const groupsOfSix = [undefined, undefined, undefined, undefined]; - groupsOfSix[0] = str.charCodeAt(i) >> 2; - groupsOfSix[1] = (str.charCodeAt(i) & 0x03) << 4; - if (str.length > i + 1) { - groupsOfSix[1] |= str.charCodeAt(i + 1) >> 4; - groupsOfSix[2] = (str.charCodeAt(i + 1) & 0x0f) << 2; - } - if (str.length > i + 2) { - groupsOfSix[2] |= str.charCodeAt(i + 2) >> 6; - groupsOfSix[3] = str.charCodeAt(i + 2) & 0x3f; - } - for (let j = 0; j < groupsOfSix.length; j++) { - if (typeof groupsOfSix[j] === "undefined") { - out += "="; - } else { - out += KEY_STRING[groupsOfSix[j]]; - } - } - } - return out; +/** @type {(base64: string) => ArrayBuffer} */ +export function decode_legacy(base64) { + const binary_string = atob(base64); + const len = binary_string.length; + const array = new Uint8Array(len); + + for (let i = 0; i < len; i++) { + array[i] = binary_string.charCodeAt(i); + } + + return array.buffer; } + +const native = typeof Uint8Array.fromBase64 === 'function'; +const buffer = + typeof process === 'object' && process.versions?.node !== undefined; + +export const encode64 = native + ? encode_native + : buffer + ? encode_buffer + : encode_legacy; +export const decode64 = native + ? decode_native + : buffer + ? decode_buffer + : decode_legacy; diff --git a/src/base64.test.js b/src/base64.test.js new file mode 100644 index 0000000..1c3a878 --- /dev/null +++ b/src/base64.test.js @@ -0,0 +1,44 @@ +import * as assert from 'uvu/assert'; +import { suite } from 'uvu'; +import * as base64 from './base64.js'; + +const strings = [ + '', + 'a', + 'ab', + 'abc', + 'a\r\nb', + '\xFF\xFE', + '\x00', + '\x00\x00\x00', + 'the quick brown fox etc', + 'é', + '中文', + '+/', + '😎' +]; + +const test = suite('base64_encode_decode'); + +const encoder = new TextEncoder(); +const decoder = new TextDecoder(); + +for (const string of strings) { + test(string, () => { + const data = encoder.encode(string); + + const with_buffer = base64.encode_buffer(data); + const with_legacy = base64.encode_legacy(data); + + assert.equal(with_buffer, with_legacy); + assert.equal(decoder.decode(base64.decode_buffer(with_buffer)), string); + assert.equal(decoder.decode(base64.decode_legacy(with_legacy)), string); + + if (typeof Uint8Array.fromBase64 === 'function') { + const with_native = base64.encode_native(data); + assert.equal(decoder.decode(base64.decode_native(with_native)), string); + } + }); +} + +test.run(); diff --git a/src/parse.js b/src/parse.js index 25d667d..3c2cb0d 100644 --- a/src/parse.js +++ b/src/parse.js @@ -163,12 +163,10 @@ export function unflatten(parsed, revivers) { const TypedArrayConstructor = globalThis[type]; const buffer = hydrate(value[1]); - const typedArray = new TypedArrayConstructor(buffer); - hydrated[index] = - value[2] !== undefined - ? typedArray.subarray(value[2], value[3]) - : typedArray; + hydrated[index] = value[2] !== undefined + ? new TypedArrayConstructor(buffer, value[2], value[3]) + : new TypedArrayConstructor(buffer); break; } diff --git a/src/stringify.js b/src/stringify.js index 780644c..d2482ef 100644 --- a/src/stringify.js +++ b/src/stringify.js @@ -230,13 +230,10 @@ export function stringify(value, reducers) { const typedArray = thing; str = '["' + type + '",' + flatten(typedArray.buffer); - const a = thing.byteOffset; - const b = a + thing.byteLength; - // handle subarrays - if (a > 0 || b !== typedArray.buffer.byteLength) { - const m = +/(\d+)/.exec(type)[1] / 8; - str += `,${a / m},${b / m}`; + if (typedArray.byteLength !== typedArray.buffer.byteLength) { + // to be used with `new TypedArray(buffer, byteOffset, length)` + str += `,${typedArray.byteOffset},${typedArray.length}`; } str += ']'; diff --git a/src/uneval.js b/src/uneval.js index 7da1730..4f9bfcf 100644 --- a/src/uneval.js +++ b/src/uneval.js @@ -304,13 +304,11 @@ export function uneval(value, replacer) { str += `([${stringify(thing.buffer)}])`; } - const a = thing.byteOffset; - const b = a + thing.byteLength; - // handle subarrays - if (a > 0 || b !== thing.buffer.byteLength) { - const m = +/(\d+)/.exec(type)[1] / 8; - str += `.subarray(${a / m},${b / m})`; + if (thing.byteLength !== thing.buffer.byteLength) { + const start = thing.byteOffset / thing.BYTES_PER_ELEMENT; + const end = start + thing.length; + str += `.subarray(${start},${end})`; } return str; diff --git a/test/index.test.js b/test/index.test.js index c523bc0..4e25091 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -240,7 +240,7 @@ const fixtures = { name: 'Sliced typed array', value: new Uint16Array([10, 20, 30, 40]).subarray(1, 3), js: 'new Uint16Array([10,20,30,40]).subarray(1,3)', - json: '[["Uint16Array",1,1,3],["ArrayBuffer","CgAUAB4AKAA="]]' + json: '[["Uint16Array",1,2,2],["ArrayBuffer","CgAUAB4AKAA="]]' }, { name: 'Temporal.Duration', diff --git a/tsconfig.json b/tsconfig.json index 20357cb..95b3da2 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,7 +11,8 @@ "noImplicitThis": true, "noEmitOnError": true, "lib": ["es6", "esnext", "dom"], - "target": "esnext" + "target": "esnext", + "types": ["node"] }, "module": "ES6", "include": ["index.js", "src/*.js"],