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 @@ -12,7 +12,8 @@ jobs:
matrix:
node-version:
- 18
- '*'
- 25 # Node.js 25 support Uint8Array.fromBase64()
- 'lts/*'
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
Expand Down
99 changes: 99 additions & 0 deletions benchmarks/base64.bench.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
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, () => {
if (hasUint8ArrayFromBase64) {
bench('Uint8Array.fromBase64 + TextDecoder', () => {
decodeBase64WithUint8Array(payload.encoded);
});
}

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

bench('atob + Uint8Array.from + TextDecoder', () => {
decodeBase64WithAtob(payload.encoded);
});
});
}
});
61 changes: 61 additions & 0 deletions globals.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
// Adapted from file://./node_modules/typescript/lib/lib.dom.d.ts so we don't have to include the entire DOM lib
// Ref: https://github.com/microsoft/TypeScript/issues/31535, https://github.com/microsoft/TypeScript/issues/41727, https://github.com/microsoft/TypeScript-DOM-lib-generator/issues/1685

/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/atob) */
declare function atob(data: string): string;
/** [MDN Reference](https://developer.mozilla.org/docs/Web/API/Window/btoa) */
declare function btoa(data: string): string;

type AllowSharedBufferSource =
| ArrayBufferLike
| ArrayBufferView<ArrayBufferLike>;

interface TextDecodeOptions {
stream?: boolean;
}

interface TextDecoderOptions {
fatal?: boolean;
ignoreBOM?: boolean;
}

/**
* The **`TextDecoder`** interface represents a decoder for a specific text encoding, such as `UTF-8`, `ISO-8859-2`, `KOI8-R`, `GBK`, etc.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder)
*/
interface TextDecoder extends TextDecoderCommon {
/**
* The **`TextDecoder.decode()`** method returns a string containing text decoded from the buffer passed as a parameter.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/decode)
*/
decode(input?: AllowSharedBufferSource, options?: TextDecodeOptions): string;
}

// eslint-disable-next-line no-var
declare var TextDecoder: {
prototype: TextDecoder;
new (label?: string, options?: TextDecoderOptions): TextDecoder;
};

interface TextDecoderCommon {
/**
* Returns encoding's name, lowercased.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/encoding)
*/
readonly encoding: string;
/**
* Returns true if error mode is "fatal", otherwise false.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/fatal)
*/
readonly fatal: boolean;
/**
* Returns the value of ignore BOM.
*
* [MDN Reference](https://developer.mozilla.org/docs/Web/API/TextDecoder/ignoreBOM)
*/
readonly ignoreBOM: boolean;
}
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"dist/"
],
"scripts": {
"bench": "vitest bench",
"build": "ts-scripts build",
"format": "ts-scripts format",
"lint": "ts-scripts lint",
Expand All @@ -27,7 +28,6 @@
},
"devDependencies": {
"@borderless/ts-scripts": "^0.15.0",
"@types/node": "^20.19.35",
"@vitest/coverage-v8": "^3.2.4",
"typescript": "^5.9.3",
"vitest": "^3.2.4"
Expand Down
39 changes: 34 additions & 5 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,6 @@
* MIT Licensed
*/

import { Buffer } from 'node:buffer';

/**
* Object to represent user credentials.
*/
Expand Down Expand Up @@ -70,14 +68,45 @@ const CREDENTIALS_REGEXP =

const USER_PASS_REGEXP = /^([^:]*):(.*)$/;

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

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

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

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

/**
* Decode base64 string.
* @private
*/
const decodeBase64: (str: string) => string = (() => {
// 1) Node.js (fast path)
if (typeof NodeBuffer?.from === 'function') {
return (str: string) => NodeBuffer.from(str, 'base64').toString('utf-8');
}

function decodeBase64(str: string): string {
return Buffer.from(str, 'base64').toString();
}
// 2) Modern Web / some runtimes
if (typeof (Uint8Array as Uint8ArrayWithBase64).fromBase64 === 'function') {
return (str: string) =>
textDecoder.decode((Uint8Array as Uint8ArrayWithBase64).fromBase64!(str));
}

// 3) Browser fallback
return (str: string) => {
const binary = atob(str);
return textDecoder.decode(
Uint8Array.from(binary, (char) => char.charCodeAt(0)),
);
};
})();

class CredentialsImpl implements Credentials {
name: string;
Expand Down
2 changes: 1 addition & 1 deletion tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
"outDir": "dist",
"module": "nodenext",
"moduleResolution": "nodenext",
"types": ["node"]
"types": ["./globals.d.ts"]
},
"include": ["src/**/*"]
}
Loading