Skip to content
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/brown-carrots-shave.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devalue": minor
---

feat: use native alternatives to encode/decode base64
5 changes: 5 additions & 0 deletions .changeset/happy-mugs-happen.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"devalue": minor
---

feat: simplify TypedArray slices
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
151 changes: 55 additions & 96 deletions src/base64.js
Original file line number Diff line number Diff line change
@@ -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;
44 changes: 44 additions & 0 deletions src/base64.test.js
Original file line number Diff line number Diff line change
@@ -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();
8 changes: 3 additions & 5 deletions src/parse.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Expand Down
9 changes: 3 additions & 6 deletions src/stringify.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 += ']';
Expand Down
10 changes: 4 additions & 6 deletions src/uneval.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion test/index.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
Loading