From 174b60fe2943844ee647d24f8348ccb93334ee4e Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Wed, 13 May 2026 21:16:15 +0200 Subject: [PATCH] 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. --- .github/workflows/ci.yml | 2 +- package.json | 7 +- src/base64.bench.ts | 137 +++++++++++++++++++++++++++++++++++++++ src/index.ts | 14 ++-- src/parse.spec.ts | 6 ++ tsconfig.json | 2 +- 6 files changed, 160 insertions(+), 8 deletions(-) create mode 100644 src/base64.bench.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fff328..e71df84 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -11,7 +11,7 @@ jobs: strategy: matrix: node-version: - - 18 + - 20 - '*' steps: - uses: actions/checkout@v6 diff --git a/package.json b/package.json index e18a931..d952ee0 100644 --- a/package.json +++ b/package.json @@ -26,15 +26,18 @@ "specs": "ts-scripts specs", "test": "ts-scripts test" }, + "dependencies": { + "@exodus/bytes": "^1.15.0" + }, "devDependencies": { "@borderless/ts-scripts": "^0.15.0", - "@types/node": "^20.19.35", "@vitest/coverage-v8": "^3.2.4", + "base64-js": "^1.5.1", "typescript": "^5.9.3", "vitest": "^3.2.4" }, "engines": { - "node": ">=18" + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" }, "ts-scripts": { "dist": [ diff --git a/src/base64.bench.ts b/src/base64.bench.ts new file mode 100644 index 0000000..fcedf69 --- /dev/null +++ b/src/base64.bench.ts @@ -0,0 +1,137 @@ +import { utf8fromString, utf8toString } from '@exodus/bytes/utf8.js'; +import { fromBase64, toBase64 } from '@exodus/bytes/base64.js'; +import base64js = require('base64-js'); +import { describe, bench } from 'vitest'; + +const hasBufferSupport = typeof (globalThis as any).Buffer !== 'undefined'; +const Buffer = (globalThis as any).Buffer; + +const hasUint8ArrayBase64Support = + typeof (Uint8Array as any).fromBase64 === 'function' && + typeof (Uint8Array.prototype as any).toBase64 === 'function'; + +const textDecoder = new (globalThis as any).TextDecoder('utf-8', { + fatal: true, + ignoreBOM: true, +}); +const textEncoder = new (globalThis as any).TextEncoder('utf-8'); + +function decodeWithBuffer(str: string): string { + return Buffer.from(str, 'base64').toString(); +} +function encodeWithBuffer(str: string): string { + return Buffer.from(str, 'utf-8').toString('base64'); +} + +function decodeWithExodusBytes(str: string): string { + return utf8toString(fromBase64(str)); +} +function encodeWithExodusBytes(str: string): string { + return toBase64(utf8fromString(str)); +} + +function decodeWithBase64Js(str: string): string { + return textDecoder.decode(base64js.toByteArray(str)); +} +function encodeWithBase64Js(str: string): string { + return base64js.fromByteArray(textEncoder.encode(str)); +} + +function decodeWithUint8Array(str: string): string { + return textDecoder.decode( + ( + Uint8Array as typeof Uint8Array & { + fromBase64: (str: string) => Uint8Array; + } + ).fromBase64(str), + ); +} +function encodeWithUint8Array(str: string): string { + return textEncoder.encode(str).toBase64(); +} + +describe('base64 decode - short', () => { + const str = 'dGVzdDpwYXNzd29yZA=='; // "test:password" + + bench('decode @exodus/bytes', () => { + decodeWithExodusBytes(str); + }); + bench('decode base64-js', () => { + decodeWithBase64Js(str); + }); + if (hasUint8ArrayBase64Support) { + bench('decode Uint8Array.fromBase64', () => { + decodeWithUint8Array(str); + }); + } + if (hasBufferSupport) { + bench('decode Buffer', () => { + decodeWithBuffer(str); + }); + } +}); + +describe('base64 encode - short', () => { + const str = 'test:password'; + + bench('encode @exodus/bytes', () => { + encodeWithExodusBytes(str); + }); + bench('encode base64-js', () => { + encodeWithBase64Js(str); + }); + if (hasUint8ArrayBase64Support) { + bench('encode Uint8Array.toBase64', () => { + encodeWithUint8Array(str); + }); + } + if (hasBufferSupport) { + bench('encode Buffer', () => { + encodeWithBuffer(str); + }); + } +}); + +describe('base64 decode - long', () => { + const str = + 'VGhpcyBpcyBhIHZlcnkgbG9uZyBzdHJpbmcgdGhhdCB3aWxsIGJlIHVzZWQgdG8gYmVuY2htYXJrIHRoZSBiYXNlNjQgZGVjb2RlIHJlc3BvbnNlIG9mIHRoZSBiYXNpYyBhdXRoIHBhcnNlIGZ1bmN0aW9uLg=='; // "This is a very long string that will be used to benchmark the base64 decode response of the basic auth parse function." + + bench('decode @exodus/bytes', () => { + decodeWithExodusBytes(str); + }); + bench('decode base64-js', () => { + decodeWithBase64Js(str); + }); + if (hasUint8ArrayBase64Support) { + bench('decode Uint8Array.fromBase64', () => { + decodeWithUint8Array(str); + }); + } + if (hasBufferSupport) { + bench('decode Buffer', () => { + decodeWithBuffer(str); + }); + } +}); + +describe('base64 encode - long', () => { + const str = + 'This is a very long string that will be used to benchmark the base64 encode response of the basic auth format function.'; + + bench('encode @exodus/bytes', () => { + encodeWithExodusBytes(str); + }); + bench('encode base64-js', () => { + encodeWithBase64Js(str); + }); + if (hasUint8ArrayBase64Support) { + bench('encode Uint8Array.toBase64', () => { + encodeWithUint8Array(str); + }); + } + if (hasBufferSupport) { + bench('encode Buffer', () => { + encodeWithBuffer(str); + }); + } +}); diff --git a/src/index.ts b/src/index.ts index 3289d9f..2551075 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,7 +6,8 @@ * MIT Licensed */ -import { Buffer } from 'node:buffer'; +import { utf8fromString, utf8toString } from '@exodus/bytes/utf8.js'; +import { fromBase64, toBase64 } from '@exodus/bytes/base64.js'; /** * Object to represent user credentials. @@ -34,7 +35,12 @@ export function parse(string: string): Credentials | undefined { if (!match) return undefined; // decode user pass - const userPass = decodeBase64(match[1]); + let userPass: string; + try { + userPass = decodeBase64(match[1]); + } catch { + return undefined; + } const colonIndex = userPass.indexOf(':'); if (colonIndex === -1) return undefined; @@ -110,7 +116,7 @@ const CONTROL_CHARS_REGEXP = /[\x00-\x1F\x7F]/; * @private */ function decodeBase64(str: string): string { - return Buffer.from(str, 'base64').toString(); + return utf8toString(fromBase64(str)); } /** @@ -118,5 +124,5 @@ function decodeBase64(str: string): string { * @private */ function encodeBase64(str: string): string { - return Buffer.from(str, 'utf-8').toString('base64'); + return toBase64(utf8fromString(str)); } diff --git a/src/parse.spec.ts b/src/parse.spec.ts index 4f3f692..285f7e5 100644 --- a/src/parse.spec.ts +++ b/src/parse.spec.ts @@ -26,6 +26,12 @@ describe('parse(string)', function () { }); }); + describe('with invalid base64 credentials', function () { + it('should return undefined', function () { + assert.strictEqual(parse('basic invalidbase64'), undefined); + }); + }); + describe('with valid credentials', function () { it('should return .name and .pass', function () { var creds = parse('basic Zm9vOmJhcg=='); diff --git a/tsconfig.json b/tsconfig.json index 8eef991..ff015bb 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "dist", "module": "nodenext", "moduleResolution": "nodenext", - "types": ["node"] + "types": [] }, "include": ["src/**/*"] }