diff --git a/src/dedupeRequestHash.ts b/src/dedupeRequestHash.ts index 1e75bd7..81f6040 100644 --- a/src/dedupeRequestHash.ts +++ b/src/dedupeRequestHash.ts @@ -16,6 +16,30 @@ export type DedupeHashParams = { request?: Request } +function toBase64(bytes: Uint8Array): string { + // Use Node Buffer when available, otherwise fall back to btoa for browser-like runtimes. + const maybeBuffer = ( + globalThis as { + Buffer?: { + from(input: Uint8Array): { toString(encoding: string): string } + } + } + ).Buffer + if (maybeBuffer) { + return maybeBuffer.from(bytes).toString('base64') + } + + let binary = '' + for (const byte of bytes) { + binary += String.fromCharCode(byte) + } + if (typeof btoa === 'function') { + return btoa(binary) + } + + throw new Error('Base64 encoding is not available in this runtime') +} + export function dedupeRequestHash( params: DedupeHashParams ): string | undefined { @@ -34,9 +58,9 @@ export function dedupeRequestHash( } else if (body instanceof URLSearchParams) { bodyString = body.toString() } else if (body instanceof ArrayBuffer) { - bodyString = Buffer.from(body).toString('base64') + bodyString = toBase64(new Uint8Array(body)) } else if (body instanceof Uint8Array) { - bodyString = Buffer.from(body).toString('base64') + bodyString = toBase64(body) } else if (body instanceof Blob) { bodyString = `[blob:${body.type}:${body.size}]` } else if (body == null) { diff --git a/test/plugins/dedupeRequestHash.test.ts b/test/plugins/dedupeRequestHash.test.ts index 5eb1636..9f244d4 100644 --- a/test/plugins/dedupeRequestHash.test.ts +++ b/test/plugins/dedupeRequestHash.test.ts @@ -1,10 +1,18 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, afterEach } from 'vitest' import { dedupeRequestHash, DedupeHashParams, } from '../../src/dedupeRequestHash' describe('dedupeRequestHash', () => { + const originalBuffer = (globalThis as { Buffer?: unknown }).Buffer + const originalBtoa = (globalThis as { btoa?: unknown }).btoa + + afterEach(() => { + ;(globalThis as { Buffer?: unknown }).Buffer = originalBuffer + ;(globalThis as { btoa?: unknown }).btoa = originalBtoa + }) + it('returns undefined for FormData body', () => { const form = new FormData() form.append('foo', 'bar') @@ -100,4 +108,36 @@ describe('dedupeRequestHash', () => { const hash = dedupeRequestHash(params) expect(hash).toContain('[unserializable-body]') }) + + it('falls back to btoa when Buffer is unavailable', () => { + ;(globalThis as { Buffer?: unknown }).Buffer = undefined + ;(globalThis as { btoa?: (input: string) => string }).btoa = (input) => { + if (input === 'abc') return 'YWJj' + throw new Error('unexpected btoa input') + } + + const buf = new Uint8Array([97, 98, 99]).buffer + const params: DedupeHashParams = { + method: 'PUT', + url: 'https://example.com', + body: buf, + } + + expect(dedupeRequestHash(params)).toBe('PUT|https://example.com|YWJj') + }) + + it('throws when neither Buffer nor btoa are available', () => { + ;(globalThis as { Buffer?: unknown }).Buffer = undefined + ;(globalThis as { btoa?: unknown }).btoa = undefined + + const params: DedupeHashParams = { + method: 'PATCH', + url: 'https://example.com', + body: new Uint8Array([1, 2, 3]), + } + + expect(() => dedupeRequestHash(params)).toThrow( + 'Base64 encoding is not available in this runtime' + ) + }) })