Skip to content

Commit 174b60f

Browse files
committed
feat: replace Buffer-based base64 paths with engine-neutral paths
Migrate base64 encode/decode paths away from Node.js-specific Buffer to @exodus/bytes for cross-engine compatibility. Add base64 benchmarks for Buffer, @exodus/bytes, base64-js, and Uint8Array.fromBase64()/toBase64() with TextEncoder/TextDecoder, using runtime guards.
1 parent 1ba386f commit 174b60f

6 files changed

Lines changed: 160 additions & 8 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ jobs:
1111
strategy:
1212
matrix:
1313
node-version:
14-
- 18
14+
- 20
1515
- '*'
1616
steps:
1717
- uses: actions/checkout@v6

package.json

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,15 +26,18 @@
2626
"specs": "ts-scripts specs",
2727
"test": "ts-scripts test"
2828
},
29+
"dependencies": {
30+
"@exodus/bytes": "^1.15.0"
31+
},
2932
"devDependencies": {
3033
"@borderless/ts-scripts": "^0.15.0",
31-
"@types/node": "^20.19.35",
3234
"@vitest/coverage-v8": "^3.2.4",
35+
"base64-js": "^1.5.1",
3336
"typescript": "^5.9.3",
3437
"vitest": "^3.2.4"
3538
},
3639
"engines": {
37-
"node": ">=18"
40+
"node": "^20.19.0 || ^22.12.0 || >=24.0.0"
3841
},
3942
"ts-scripts": {
4043
"dist": [

src/base64.bench.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { utf8fromString, utf8toString } from '@exodus/bytes/utf8.js';
2+
import { fromBase64, toBase64 } from '@exodus/bytes/base64.js';
3+
import base64js = require('base64-js');
4+
import { describe, bench } from 'vitest';
5+
6+
const hasBufferSupport = typeof (globalThis as any).Buffer !== 'undefined';
7+
const Buffer = (globalThis as any).Buffer;
8+
9+
const hasUint8ArrayBase64Support =
10+
typeof (Uint8Array as any).fromBase64 === 'function' &&
11+
typeof (Uint8Array.prototype as any).toBase64 === 'function';
12+
13+
const textDecoder = new (globalThis as any).TextDecoder('utf-8', {
14+
fatal: true,
15+
ignoreBOM: true,
16+
});
17+
const textEncoder = new (globalThis as any).TextEncoder('utf-8');
18+
19+
function decodeWithBuffer(str: string): string {
20+
return Buffer.from(str, 'base64').toString();
21+
}
22+
function encodeWithBuffer(str: string): string {
23+
return Buffer.from(str, 'utf-8').toString('base64');
24+
}
25+
26+
function decodeWithExodusBytes(str: string): string {
27+
return utf8toString(fromBase64(str));
28+
}
29+
function encodeWithExodusBytes(str: string): string {
30+
return toBase64(utf8fromString(str));
31+
}
32+
33+
function decodeWithBase64Js(str: string): string {
34+
return textDecoder.decode(base64js.toByteArray(str));
35+
}
36+
function encodeWithBase64Js(str: string): string {
37+
return base64js.fromByteArray(textEncoder.encode(str));
38+
}
39+
40+
function decodeWithUint8Array(str: string): string {
41+
return textDecoder.decode(
42+
(
43+
Uint8Array as typeof Uint8Array & {
44+
fromBase64: (str: string) => Uint8Array;
45+
}
46+
).fromBase64(str),
47+
);
48+
}
49+
function encodeWithUint8Array(str: string): string {
50+
return textEncoder.encode(str).toBase64();
51+
}
52+
53+
describe('base64 decode - short', () => {
54+
const str = 'dGVzdDpwYXNzd29yZA=='; // "test:password"
55+
56+
bench('decode @exodus/bytes', () => {
57+
decodeWithExodusBytes(str);
58+
});
59+
bench('decode base64-js', () => {
60+
decodeWithBase64Js(str);
61+
});
62+
if (hasUint8ArrayBase64Support) {
63+
bench('decode Uint8Array.fromBase64', () => {
64+
decodeWithUint8Array(str);
65+
});
66+
}
67+
if (hasBufferSupport) {
68+
bench('decode Buffer', () => {
69+
decodeWithBuffer(str);
70+
});
71+
}
72+
});
73+
74+
describe('base64 encode - short', () => {
75+
const str = 'test:password';
76+
77+
bench('encode @exodus/bytes', () => {
78+
encodeWithExodusBytes(str);
79+
});
80+
bench('encode base64-js', () => {
81+
encodeWithBase64Js(str);
82+
});
83+
if (hasUint8ArrayBase64Support) {
84+
bench('encode Uint8Array.toBase64', () => {
85+
encodeWithUint8Array(str);
86+
});
87+
}
88+
if (hasBufferSupport) {
89+
bench('encode Buffer', () => {
90+
encodeWithBuffer(str);
91+
});
92+
}
93+
});
94+
95+
describe('base64 decode - long', () => {
96+
const str =
97+
'VGhpcyBpcyBhIHZlcnkgbG9uZyBzdHJpbmcgdGhhdCB3aWxsIGJlIHVzZWQgdG8gYmVuY2htYXJrIHRoZSBiYXNlNjQgZGVjb2RlIHJlc3BvbnNlIG9mIHRoZSBiYXNpYyBhdXRoIHBhcnNlIGZ1bmN0aW9uLg=='; // "This is a very long string that will be used to benchmark the base64 decode response of the basic auth parse function."
98+
99+
bench('decode @exodus/bytes', () => {
100+
decodeWithExodusBytes(str);
101+
});
102+
bench('decode base64-js', () => {
103+
decodeWithBase64Js(str);
104+
});
105+
if (hasUint8ArrayBase64Support) {
106+
bench('decode Uint8Array.fromBase64', () => {
107+
decodeWithUint8Array(str);
108+
});
109+
}
110+
if (hasBufferSupport) {
111+
bench('decode Buffer', () => {
112+
decodeWithBuffer(str);
113+
});
114+
}
115+
});
116+
117+
describe('base64 encode - long', () => {
118+
const str =
119+
'This is a very long string that will be used to benchmark the base64 encode response of the basic auth format function.';
120+
121+
bench('encode @exodus/bytes', () => {
122+
encodeWithExodusBytes(str);
123+
});
124+
bench('encode base64-js', () => {
125+
encodeWithBase64Js(str);
126+
});
127+
if (hasUint8ArrayBase64Support) {
128+
bench('encode Uint8Array.toBase64', () => {
129+
encodeWithUint8Array(str);
130+
});
131+
}
132+
if (hasBufferSupport) {
133+
bench('encode Buffer', () => {
134+
encodeWithBuffer(str);
135+
});
136+
}
137+
});

src/index.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
* MIT Licensed
77
*/
88

9-
import { Buffer } from 'node:buffer';
9+
import { utf8fromString, utf8toString } from '@exodus/bytes/utf8.js';
10+
import { fromBase64, toBase64 } from '@exodus/bytes/base64.js';
1011

1112
/**
1213
* Object to represent user credentials.
@@ -34,7 +35,12 @@ export function parse(string: string): Credentials | undefined {
3435
if (!match) return undefined;
3536

3637
// decode user pass
37-
const userPass = decodeBase64(match[1]);
38+
let userPass: string;
39+
try {
40+
userPass = decodeBase64(match[1]);
41+
} catch {
42+
return undefined;
43+
}
3844
const colonIndex = userPass.indexOf(':');
3945
if (colonIndex === -1) return undefined;
4046

@@ -110,13 +116,13 @@ const CONTROL_CHARS_REGEXP = /[\x00-\x1F\x7F]/;
110116
* @private
111117
*/
112118
function decodeBase64(str: string): string {
113-
return Buffer.from(str, 'base64').toString();
119+
return utf8toString(fromBase64(str));
114120
}
115121

116122
/**
117123
* Encode string to base64.
118124
* @private
119125
*/
120126
function encodeBase64(str: string): string {
121-
return Buffer.from(str, 'utf-8').toString('base64');
127+
return toBase64(utf8fromString(str));
122128
}

src/parse.spec.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,12 @@ describe('parse(string)', function () {
2626
});
2727
});
2828

29+
describe('with invalid base64 credentials', function () {
30+
it('should return undefined', function () {
31+
assert.strictEqual(parse('basic invalidbase64'), undefined);
32+
});
33+
});
34+
2935
describe('with valid credentials', function () {
3036
it('should return .name and .pass', function () {
3137
var creds = parse('basic Zm9vOmJhcg==');

tsconfig.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
"outDir": "dist",
88
"module": "nodenext",
99
"moduleResolution": "nodenext",
10-
"types": ["node"]
10+
"types": []
1111
},
1212
"include": ["src/**/*"]
1313
}

0 commit comments

Comments
 (0)