diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fff328..f64634c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,8 @@ jobs: strategy: matrix: node-version: - - 18 + - 20 + - 25 # Node.js 25 support Uint8Array.fromBase64() - '*' steps: - uses: actions/checkout@v6 diff --git a/benchmarks/base64.bench.ts b/benchmarks/base64.bench.ts new file mode 100644 index 0000000..90c4271 --- /dev/null +++ b/benchmarks/base64.bench.ts @@ -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); + }); + }); + } +}); diff --git a/package.json b/package.json index c9b588c..a78e8c9 100644 --- a/package.json +++ b/package.json @@ -28,11 +28,11 @@ "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" @@ -40,9 +40,6 @@ "size-limit": [ { "path": "dist/index.js", - "ignore": [ - "node:buffer" - ], "limit": "1 kB" } ], diff --git a/src/base64.spec.ts b/src/base64.spec.ts new file mode 100644 index 0000000..2abbfa9 --- /dev/null +++ b/src/base64.spec.ts @@ -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'); + }); + }); +}); diff --git a/src/base64.ts b/src/base64.ts new file mode 100644 index 0000000..30be663 --- /dev/null +++ b/src/base64.ts @@ -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); + }, + }; +})(); diff --git a/src/index.ts b/src/index.ts index 3289d9f..d4d4b1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,7 @@ * MIT Licensed */ -import { Buffer } from 'node:buffer'; +import { base64 } from './base64.js'; /** * Object to represent user credentials. @@ -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; @@ -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); } /** @@ -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'); -} diff --git a/tsconfig.json b/tsconfig.json index 4a23eec..57f2fa9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,7 +2,7 @@ "extends": "@borderless/ts-scripts/configs/tsconfig.json", "compilerOptions": { "target": "es2023", - "lib": ["es2023"], + "lib": ["ESNext"], "rootDir": "src", "outDir": "dist", "module": "nodenext",