From fc15affab10fca4fbad7df47cb1217669983bd4a Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Fri, 27 Feb 2026 01:25:13 +0100 Subject: [PATCH 1/2] refactor: add cross-environment decodeBase64 method --- .github/workflows/ci.yml | 3 +- globals.d.ts | 61 ++++++++++++++++++++++++++++++++++++++++ package.json | 1 - src/index.ts | 39 +++++++++++++++++++++---- tsconfig.json | 2 +- 5 files changed, 98 insertions(+), 8 deletions(-) create mode 100644 globals.d.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fff328..0612a79 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/globals.d.ts b/globals.d.ts new file mode 100644 index 0000000..20fd8b2 --- /dev/null +++ b/globals.d.ts @@ -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; + +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; +} diff --git a/package.json b/package.json index dde152b..98fe6ac 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,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" diff --git a/src/index.ts b/src/index.ts index 98bc6c4..e90e7de 100644 --- a/src/index.ts +++ b/src/index.ts @@ -6,8 +6,6 @@ * MIT Licensed */ -import { Buffer } from 'node:buffer'; - export = auth; /** @@ -101,14 +99,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) Modern Web / some runtimes + if (typeof (Uint8Array as Uint8ArrayWithBase64).fromBase64 === 'function') { + return (str: string) => + textDecoder.decode((Uint8Array as Uint8ArrayWithBase64).fromBase64!(str)); + } -function decodeBase64(str: string): string { - return Buffer.from(str, 'base64').toString(); -} + // 2) Node.js (fast path) + if (typeof NodeBuffer?.from === 'function') { + return (str: string) => NodeBuffer.from(str, 'base64').toString('utf-8'); + } + + // 3) Browser fallback + return (str: string) => { + const binary = atob(str); + return textDecoder.decode( + Uint8Array.from(binary, (char) => char.charCodeAt(0)), + ); + }; +})(); /** * Get the Authorization header from request object. diff --git a/tsconfig.json b/tsconfig.json index 8eef991..0e01057 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,7 @@ "outDir": "dist", "module": "nodenext", "moduleResolution": "nodenext", - "types": ["node"] + "types": ["./globals.d.ts"] }, "include": ["src/**/*"] } From 2b6d3e054e0a74b90d0e353fd187ad2819657fac Mon Sep 17 00:00:00 2001 From: Phillip Barta Date: Thu, 23 Apr 2026 15:34:53 +0200 Subject: [PATCH 2/2] add base64 benchmarks and switch imlementations based on result --- benchmarks/base64.bench.ts | 99 ++++++++++++++++++++++++++++++++++++++ package.json | 1 + src/index.ts | 12 ++--- 3 files changed, 106 insertions(+), 6 deletions(-) create mode 100644 benchmarks/base64.bench.ts diff --git a/benchmarks/base64.bench.ts b/benchmarks/base64.bench.ts new file mode 100644 index 0000000..3b3d5f5 --- /dev/null +++ b/benchmarks/base64.bench.ts @@ -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); + }); + }); + } +}); diff --git a/package.json b/package.json index 98fe6ac..68f348f 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "dist/" ], "scripts": { + "bench": "vitest bench", "build": "ts-scripts build", "format": "ts-scripts format", "lint": "ts-scripts lint", diff --git a/src/index.ts b/src/index.ts index e90e7de..16a7c73 100644 --- a/src/index.ts +++ b/src/index.ts @@ -119,17 +119,17 @@ const textDecoder = new TextDecoder('utf-8'); * @private */ const decodeBase64: (str: string) => string = (() => { - // 1) Modern Web / some runtimes + // 1) Node.js (fast path) + if (typeof NodeBuffer?.from === 'function') { + return (str: string) => NodeBuffer.from(str, 'base64').toString('utf-8'); + } + + // 2) Modern Web / some runtimes if (typeof (Uint8Array as Uint8ArrayWithBase64).fromBase64 === 'function') { return (str: string) => textDecoder.decode((Uint8Array as Uint8ArrayWithBase64).fromBase64!(str)); } - // 2) Node.js (fast path) - if (typeof NodeBuffer?.from === 'function') { - return (str: string) => NodeBuffer.from(str, 'base64').toString('utf-8'); - } - // 3) Browser fallback return (str: string) => { const binary = atob(str);