Skip to content
Open
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
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ jobs:
strategy:
matrix:
node-version:
- 18
- 20
- 25 # Node.js 25 support Uint8Array.fromBase64()
- '*'
steps:
- uses: actions/checkout@v6
Expand Down
101 changes: 101 additions & 0 deletions benchmarks/base64.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { bench, describe } from 'vitest';

type Uint8ArrayWithBase64 = typeof Uint8Array & {
fromBase64?: (str: string) => Uint8Array;
};

type BufferLike = {
from(
input: string,
encoding: 'base64' | 'utf-8',
): { toString(encoding: 'utf-8' | 'base64'): string };
};

const NodeBuffer = (globalThis as any).Buffer as BufferLike | undefined;

const textDecoder = new TextDecoder('utf-8');

function decodeBase64WithUint8Array(str: string): string {
return textDecoder.decode(
(Uint8Array as Uint8ArrayWithBase64).fromBase64!(str),
);
}

function decodeBase64WithNodeBuffer(str: string): string {
return NodeBuffer!.from(str, 'base64').toString('utf-8');
}

function decodeBase64WithAtob(str: string): string {
const binary = atob(str);
return textDecoder.decode(
Uint8Array.from(binary, (char) => char.charCodeAt(0)),
);
}

const hasUint8ArrayFromBase64 =
typeof (Uint8Array as Uint8ArrayWithBase64).fromBase64 === 'function';
const hasNodeBuffer = typeof NodeBuffer?.from === 'function';

const payloads = [
{
name: 'short username/password',
decoded: 'user:password',
},
{
name: 'common API style credential',
decoded: 'api-client-42:sk_live_b36WzMj0n95wE1y8hHkR2iS4qT7vNuPx',
},
{
name: 'long token-like password',
decoded:
'service-account:7f2d9c31f7f14131a65d5315f2dbdb34dc5ddacb4f57b74a04a066f53f8e92bf',
},
].map((payload) => ({
...payload,
encoded: NodeBuffer!.from(payload.decoded, 'utf-8').toString('base64'),
}));

// Sanity check that all decoding methods produce the same results before benchmarking
for (const payload of payloads) {
if (decodeBase64WithAtob(payload.encoded) !== payload.decoded) {
throw new Error(`atob decode failed for ${payload.name}`);
}

if (
hasUint8ArrayFromBase64 &&
decodeBase64WithUint8Array(payload.encoded) !== payload.decoded
) {
throw new Error(`Uint8Array.fromBase64 decode failed for ${payload.name}`);
}

if (
hasNodeBuffer &&
decodeBase64WithNodeBuffer(payload.encoded) !== payload.decoded
) {
throw new Error(`Buffer decode failed for ${payload.name}`);
}
}

describe('decode base64 for basic-auth payloads', () => {
for (const payload of payloads) {
describe(payload.name, () => {
bench.skipIf(!hasUint8ArrayFromBase64)(
'Uint8Array.fromBase64 + TextDecoder',
() => {
decodeBase64WithUint8Array(payload.encoded);
},
);

bench.skipIf(!hasNodeBuffer)(
'Buffer.from(base64).toString(utf-8)',
() => {
decodeBase64WithNodeBuffer(payload.encoded);
},
);

bench('atob + Uint8Array.from + TextDecoder', () => {
decodeBase64WithAtob(payload.encoded);
});
});
}
});
11 changes: 4 additions & 7 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,21 +28,18 @@
"devDependencies": {
"@borderless/ts-scripts": "^0.15.0",
"@size-limit/preset-small-lib": "^12.1.0",
"@types/node": "^20.19.35",
"@vitest/coverage-v8": "^3.2.4",
"@types/node": "^25.7.0",
"@vitest/coverage-v8": "^4.1.6",
"size-limit": "^12.1.0",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
"typescript": "^6.0.3",
"vitest": "^4.1.6"
},
"engines": {
"node": ">=18"
},
"size-limit": [
{
"path": "dist/index.js",
"ignore": [
"node:buffer"
],
"limit": "1 kB"
}
],
Expand Down
41 changes: 41 additions & 0 deletions src/base64.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { afterAll, assert, beforeAll, describe, it, vi } from 'vitest';

const importBase64 = async () => {
vi.resetModules();

const { base64 } = await import('./base64.js');
return base64;
};

describe('base64', async () => {
let base64 = await importBase64();

afterAll(() => {
vi.unstubAllGlobals();
});

describe.skipIf(!Buffer)('Buffer', async () => {
it('should encode base64', () => {
assert.strictEqual(base64.encode('foo:bar'), 'Zm9vOmJhcg==');
});

it('should decode base64', () => {
assert.strictEqual(base64.decode('Zm9vOmJhcg=='), 'foo:bar');
});
});

describe.skipIf(!Uint8Array.prototype.toBase64)('Uint8Array', async () => {
beforeAll(async () => {
vi.stubGlobal('Buffer', undefined);
base64 = await importBase64();
});

it('should encode base64', () => {
assert.strictEqual(base64.encode('foo:bar'), 'Zm9vOmJhcg==');
});

it('should decode base64', () => {
assert.strictEqual(base64.decode('Zm9vOmJhcg=='), 'foo:bar');
});
});
});
22 changes: 22 additions & 0 deletions src/base64.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
export const base64 = (() => {
if (typeof Buffer !== 'undefined') {
return {
encode: (str: string) => Buffer.from(str, 'utf-8').toString('base64'),
decode: (str: string) => Buffer.from(str, 'base64').toString('utf-8'),
};
}

const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();

return {
encode: (str: string) => {
const bytes = textEncoder.encode(str);
return bytes.toBase64();
},
decode: (str: string) => {
const bytes = Uint8Array.fromBase64(str);
return textDecoder.decode(bytes);
},
};
})();
22 changes: 3 additions & 19 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* MIT Licensed
*/

import { Buffer } from 'node:buffer';
import { base64 } from './base64.js';

/**
* Object to represent user credentials.
Expand Down Expand Up @@ -34,7 +34,7 @@ export function parse(string: string): Credentials | undefined {
if (!match) return undefined;

// decode user pass
const userPass = decodeBase64(match[1]);
const userPass = base64.decode(match[1]);
const colonIndex = userPass.indexOf(':');
if (colonIndex === -1) return undefined;

Expand Down Expand Up @@ -84,7 +84,7 @@ export function format(credentials: Credentials): string {
);
}

return 'Basic ' + encodeBase64(credentials.name + ':' + credentials.pass);
return 'Basic ' + base64.encode(credentials.name + ':' + credentials.pass);
}

/**
Expand All @@ -104,19 +104,3 @@ const CREDENTIALS_REGEXP =
* @private
*/
const CONTROL_CHARS_REGEXP = /[\x00-\x1F\x7F]/;

/**
* Decode base64 string.
* @private
*/
function decodeBase64(str: string): string {
return Buffer.from(str, 'base64').toString();
}

/**
* Encode string to base64.
* @private
*/
function encodeBase64(str: string): string {
return Buffer.from(str, 'utf-8').toString('base64');
}
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"extends": "@borderless/ts-scripts/configs/tsconfig.json",
"compilerOptions": {
"target": "es2023",
"lib": ["es2023"],
"lib": ["ESNext"],
"rootDir": "src",
"outDir": "dist",
"module": "nodenext",
Expand Down
Loading