Skip to content

Commit df2e284

Browse files
mrkishiRich-Harris
andauthored
feat: use native alternatives to encode/decode base64 (#136)
* fix: rename test file so uvu picks it up * chore: add simple benchmarks Based on Svelte's benchmark scaffolding. * feat: simplify TypedArray slices * feat: use native alternatives to encode/decode base64 * add some tests --------- Co-authored-by: Rich Harris <rich.harris@vercel.com>
1 parent 26b7c8d commit df2e284

10 files changed

Lines changed: 123 additions & 116 deletions

File tree

.changeset/brown-carrots-shave.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"devalue": minor
3+
---
4+
5+
feat: use native alternatives to encode/decode base64

.changeset/happy-mugs-happen.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"devalue": minor
3+
---
4+
5+
feat: simplify TypedArray slices

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ jobs:
1818
strategy:
1919
fail-fast: false
2020
matrix:
21-
node-version: [16, 18, 20]
21+
node-version: [16, 18, 20, 22, 24, 25]
2222
os: [ubuntu-latest]
2323
steps:
2424
- run: git config --global core.autocrlf false

src/base64.js

Lines changed: 55 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,110 +1,69 @@
1-
/**
2-
* Base64 Encodes an arraybuffer
3-
* @param {ArrayBuffer} arraybuffer
4-
* @returns {string}
5-
*/
6-
export function encode64(arraybuffer) {
7-
const dv = new DataView(arraybuffer);
8-
let binaryString = "";
1+
/* Baseline 2025 runtimes */
92

10-
for (let i = 0; i < arraybuffer.byteLength; i++) {
11-
binaryString += String.fromCharCode(dv.getUint8(i));
12-
}
3+
/** @type {(array_buffer: ArrayBuffer) => string} */
4+
export function encode_native(array_buffer) {
5+
return new Uint8Array(array_buffer).toBase64();
6+
}
137

14-
return binaryToAscii(binaryString);
8+
/** @type {(base64: string) => ArrayBuffer} */
9+
export function decode_native(base64) {
10+
return Uint8Array.fromBase64(base64).buffer;
1511
}
1612

17-
/**
18-
* Decodes a base64 string into an arraybuffer
19-
* @param {string} string
20-
* @returns {ArrayBuffer}
21-
*/
22-
export function decode64(string) {
23-
const binaryString = asciiToBinary(string);
24-
const arraybuffer = new ArrayBuffer(binaryString.length);
25-
const dv = new DataView(arraybuffer);
13+
/* Node-compatible runtimes */
2614

27-
for (let i = 0; i < arraybuffer.byteLength; i++) {
28-
dv.setUint8(i, binaryString.charCodeAt(i));
29-
}
15+
/** @type {(array_buffer: ArrayBuffer) => string} */
16+
export function encode_buffer(array_buffer) {
17+
return Buffer.from(array_buffer).toString('base64');
18+
}
3019

31-
return arraybuffer;
20+
/** @type {(base64: string) => ArrayBuffer} */
21+
export function decode_buffer(base64) {
22+
return Uint8Array.from(Buffer.from(base64, 'base64')).buffer;
3223
}
3324

34-
const KEY_STRING =
35-
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
25+
/* Legacy runtimes */
3626

37-
/**
38-
* Substitute for atob since it's deprecated in node.
39-
* Does not do any input validation.
40-
*
41-
* @see https://github.com/jsdom/abab/blob/master/lib/atob.js
42-
*
43-
* @param {string} data
44-
* @returns {string}
45-
*/
46-
function asciiToBinary(data) {
47-
if (data.length % 4 === 0) {
48-
data = data.replace(/==?$/, "");
49-
}
27+
/** @type {(array_buffer: ArrayBuffer) => string} */
28+
export function encode_legacy(array_buffer) {
29+
const array = new Uint8Array(array_buffer);
30+
let binary = '';
5031

51-
let output = "";
52-
let buffer = 0;
53-
let accumulatedBits = 0;
32+
// the maximum number of arguments to String.fromCharCode.apply
33+
// should be around 0xFFFF in modern engines
34+
const chunk_size = 0x8000;
35+
for (let i = 0; i < array.length; i += chunk_size) {
36+
const chunk = array.subarray(i, i + chunk_size);
37+
binary += String.fromCharCode.apply(null, chunk);
38+
}
5439

55-
for (let i = 0; i < data.length; i++) {
56-
buffer <<= 6;
57-
buffer |= KEY_STRING.indexOf(data[i]);
58-
accumulatedBits += 6;
59-
if (accumulatedBits === 24) {
60-
output += String.fromCharCode((buffer & 0xff0000) >> 16);
61-
output += String.fromCharCode((buffer & 0xff00) >> 8);
62-
output += String.fromCharCode(buffer & 0xff);
63-
buffer = accumulatedBits = 0;
64-
}
65-
}
66-
if (accumulatedBits === 12) {
67-
buffer >>= 4;
68-
output += String.fromCharCode(buffer);
69-
} else if (accumulatedBits === 18) {
70-
buffer >>= 2;
71-
output += String.fromCharCode((buffer & 0xff00) >> 8);
72-
output += String.fromCharCode(buffer & 0xff);
73-
}
74-
return output;
40+
return btoa(binary);
7541
}
7642

77-
/**
78-
* Substitute for btoa since it's deprecated in node.
79-
* Does not do any input validation.
80-
*
81-
* @see https://github.com/jsdom/abab/blob/master/lib/btoa.js
82-
*
83-
* @param {string} str
84-
* @returns {string}
85-
*/
86-
function binaryToAscii(str) {
87-
let out = "";
88-
for (let i = 0; i < str.length; i += 3) {
89-
/** @type {[number, number, number, number]} */
90-
const groupsOfSix = [undefined, undefined, undefined, undefined];
91-
groupsOfSix[0] = str.charCodeAt(i) >> 2;
92-
groupsOfSix[1] = (str.charCodeAt(i) & 0x03) << 4;
93-
if (str.length > i + 1) {
94-
groupsOfSix[1] |= str.charCodeAt(i + 1) >> 4;
95-
groupsOfSix[2] = (str.charCodeAt(i + 1) & 0x0f) << 2;
96-
}
97-
if (str.length > i + 2) {
98-
groupsOfSix[2] |= str.charCodeAt(i + 2) >> 6;
99-
groupsOfSix[3] = str.charCodeAt(i + 2) & 0x3f;
100-
}
101-
for (let j = 0; j < groupsOfSix.length; j++) {
102-
if (typeof groupsOfSix[j] === "undefined") {
103-
out += "=";
104-
} else {
105-
out += KEY_STRING[groupsOfSix[j]];
106-
}
107-
}
108-
}
109-
return out;
43+
/** @type {(base64: string) => ArrayBuffer} */
44+
export function decode_legacy(base64) {
45+
const binary_string = atob(base64);
46+
const len = binary_string.length;
47+
const array = new Uint8Array(len);
48+
49+
for (let i = 0; i < len; i++) {
50+
array[i] = binary_string.charCodeAt(i);
51+
}
52+
53+
return array.buffer;
11054
}
55+
56+
const native = typeof Uint8Array.fromBase64 === 'function';
57+
const buffer =
58+
typeof process === 'object' && process.versions?.node !== undefined;
59+
60+
export const encode64 = native
61+
? encode_native
62+
: buffer
63+
? encode_buffer
64+
: encode_legacy;
65+
export const decode64 = native
66+
? decode_native
67+
: buffer
68+
? decode_buffer
69+
: decode_legacy;

src/base64.test.js

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import * as assert from 'uvu/assert';
2+
import { suite } from 'uvu';
3+
import * as base64 from './base64.js';
4+
5+
const strings = [
6+
'',
7+
'a',
8+
'ab',
9+
'abc',
10+
'a\r\nb',
11+
'\xFF\xFE',
12+
'\x00',
13+
'\x00\x00\x00',
14+
'the quick brown fox etc',
15+
'é',
16+
'中文',
17+
'+/',
18+
'😎'
19+
];
20+
21+
const test = suite('base64_encode_decode');
22+
23+
const encoder = new TextEncoder();
24+
const decoder = new TextDecoder();
25+
26+
for (const string of strings) {
27+
test(string, () => {
28+
const data = encoder.encode(string);
29+
30+
const with_buffer = base64.encode_buffer(data);
31+
const with_legacy = base64.encode_legacy(data);
32+
33+
assert.equal(with_buffer, with_legacy);
34+
assert.equal(decoder.decode(base64.decode_buffer(with_buffer)), string);
35+
assert.equal(decoder.decode(base64.decode_legacy(with_legacy)), string);
36+
37+
if (typeof Uint8Array.fromBase64 === 'function') {
38+
const with_native = base64.encode_native(data);
39+
assert.equal(decoder.decode(base64.decode_native(with_native)), string);
40+
}
41+
});
42+
}
43+
44+
test.run();

src/parse.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -163,12 +163,10 @@ export function unflatten(parsed, revivers) {
163163

164164
const TypedArrayConstructor = globalThis[type];
165165
const buffer = hydrate(value[1]);
166-
const typedArray = new TypedArrayConstructor(buffer);
167166

168-
hydrated[index] =
169-
value[2] !== undefined
170-
? typedArray.subarray(value[2], value[3])
171-
: typedArray;
167+
hydrated[index] = value[2] !== undefined
168+
? new TypedArrayConstructor(buffer, value[2], value[3])
169+
: new TypedArrayConstructor(buffer);
172170

173171
break;
174172
}

src/stringify.js

Lines changed: 3 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -230,13 +230,10 @@ export function stringify(value, reducers) {
230230
const typedArray = thing;
231231
str = '["' + type + '",' + flatten(typedArray.buffer);
232232

233-
const a = thing.byteOffset;
234-
const b = a + thing.byteLength;
235-
236233
// handle subarrays
237-
if (a > 0 || b !== typedArray.buffer.byteLength) {
238-
const m = +/(\d+)/.exec(type)[1] / 8;
239-
str += `,${a / m},${b / m}`;
234+
if (typedArray.byteLength !== typedArray.buffer.byteLength) {
235+
// to be used with `new TypedArray(buffer, byteOffset, length)`
236+
str += `,${typedArray.byteOffset},${typedArray.length}`;
240237
}
241238

242239
str += ']';

src/uneval.js

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -304,13 +304,11 @@ export function uneval(value, replacer) {
304304
str += `([${stringify(thing.buffer)}])`;
305305
}
306306

307-
const a = thing.byteOffset;
308-
const b = a + thing.byteLength;
309-
310307
// handle subarrays
311-
if (a > 0 || b !== thing.buffer.byteLength) {
312-
const m = +/(\d+)/.exec(type)[1] / 8;
313-
str += `.subarray(${a / m},${b / m})`;
308+
if (thing.byteLength !== thing.buffer.byteLength) {
309+
const start = thing.byteOffset / thing.BYTES_PER_ELEMENT;
310+
const end = start + thing.length;
311+
str += `.subarray(${start},${end})`;
314312
}
315313

316314
return str;

test/index.test.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -240,7 +240,7 @@ const fixtures = {
240240
name: 'Sliced typed array',
241241
value: new Uint16Array([10, 20, 30, 40]).subarray(1, 3),
242242
js: 'new Uint16Array([10,20,30,40]).subarray(1,3)',
243-
json: '[["Uint16Array",1,1,3],["ArrayBuffer","CgAUAB4AKAA="]]'
243+
json: '[["Uint16Array",1,2,2],["ArrayBuffer","CgAUAB4AKAA="]]'
244244
},
245245
{
246246
name: 'Temporal.Duration',

tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,8 @@
1111
"noImplicitThis": true,
1212
"noEmitOnError": true,
1313
"lib": ["es6", "esnext", "dom"],
14-
"target": "esnext"
14+
"target": "esnext",
15+
"types": ["node"]
1516
},
1617
"module": "ES6",
1718
"include": ["index.js", "src/*.js"],

0 commit comments

Comments
 (0)