From e6319dd26565b82bab8c1cba354247c56c593f3f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 16 Jul 2026 13:47:25 -0700 Subject: [PATCH 1/2] fix: let fetch calculate request content length --- src/core.ts | 27 +--------- tests/content-length.test.ts | 95 ++++++++++++++++++++++++++++++++++++ tests/index.test.ts | 16 ++++-- 3 files changed, 109 insertions(+), 29 deletions(-) create mode 100644 tests/content-length.test.ts diff --git a/src/core.ts b/src/core.ts index 10b9d3f..d97c6a1 100644 --- a/src/core.ts +++ b/src/core.ts @@ -288,24 +288,6 @@ export abstract class APIClient { return this.requestAPIList(Page, { method: 'get', path, ...opts }); } - private calculateContentLength(body: unknown): string | null { - if (typeof body === 'string') { - if (typeof Buffer !== 'undefined') { - return Buffer.byteLength(body, 'utf8').toString(); - } - - if (typeof TextEncoder !== 'undefined') { - const encoder = new TextEncoder(); - const encoded = encoder.encode(body); - return encoded.length.toString(); - } - } else if (ArrayBuffer.isView(body)) { - return body.byteLength.toString(); - } - - return null; - } - async buildRequest( inputOptions: FinalRequestOptions, { retryCount = 0 }: { retryCount?: number } = {}, @@ -319,8 +301,6 @@ export abstract class APIClient { : isMultipartBody(options.body) ? options.body.body : options.body ? JSON.stringify(options.body, null, 2) : null; - const contentLength = this.calculateContentLength(body); - const url = this.buildURL(path!, query, defaultBaseURL); if ('timeout' in options) validatePositiveInteger('timeout', options.timeout); options.timeout = options.timeout ?? this.timeout; @@ -342,7 +322,7 @@ export abstract class APIClient { headers[this.idempotencyHeader] = inputOptions.idempotencyKey; } - const reqHeaders = this.buildHeaders({ options, headers, contentLength, retryCount }); + const reqHeaders = this.buildHeaders({ options, headers, retryCount }); const req: RequestInit = { method, @@ -360,18 +340,13 @@ export abstract class APIClient { private buildHeaders({ options, headers, - contentLength, retryCount, }: { options: FinalRequestOptions; headers: Record; - contentLength: string | null | undefined; retryCount: number; }): Record { const reqHeaders: Record = {}; - if (contentLength) { - reqHeaders['content-length'] = contentLength; - } const defaultHeaders = this.defaultHeaders(options); applyHeadersMut(reqHeaders, defaultHeaders); diff --git a/tests/content-length.test.ts b/tests/content-length.test.ts new file mode 100644 index 0000000..abc5dd8 --- /dev/null +++ b/tests/content-length.test.ts @@ -0,0 +1,95 @@ +import Browserbase from '@browserbasehq/sdk'; +import type { Fetch, Response } from '@browserbasehq/sdk/core'; +import http from 'node:http'; +import type { AddressInfo } from 'node:net'; + +type RequestCapture = { + body: string; + contentLength: string | undefined; +}; + +async function captureRequest( + options: { fetch?: Fetch; httpAgent?: http.Agent } = {}, +): Promise { + let resolveCapture: (capture: RequestCapture) => void; + let rejectCapture: (error: Error) => void; + const captured = new Promise((resolve, reject) => { + resolveCapture = resolve; + rejectCapture = reject; + }); + + const server = http.createServer((request, response) => { + const chunks: Buffer[] = []; + + request.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + request.on('error', rejectCapture); + request.on('aborted', () => rejectCapture(new Error('request aborted before the body was received'))); + request.on('end', () => { + const body = Buffer.concat(chunks).toString('utf8'); + response.writeHead(200, { 'content-type': 'application/json' }); + response.end('{}'); + resolveCapture({ body, contentLength: request.headers['content-length'] }); + }); + }); + + await new Promise((resolve, reject) => { + server.once('error', reject); + server.listen(0, '127.0.0.1', resolve); + }); + + const { port } = server.address() as AddressInfo; + const client = new Browserbase({ + apiKey: 'My API Key', + baseURL: `http://127.0.0.1:${port}`, + maxRetries: 0, + ...options, + }); + + try { + await client.post('/foo', { body: { value: 'café 🌍' } }); + return await captured; + } finally { + server.closeAllConnections(); + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +describe('Content-Length on the wire', () => { + const prettyBody = JSON.stringify({ value: 'café 🌍' }, null, 2); + + test('native fetch calculates the byte length', async () => { + const request = await captureRequest(); + + expect(request.body).toBe(prettyBody); + expect(request.contentLength).toBe(String(Buffer.byteLength(prettyBody))); + }); + + test('node-fetch calculates the byte length when an HTTP agent is provided', async () => { + const httpAgent = new http.Agent({ keepAlive: false }); + + try { + const request = await captureRequest({ httpAgent }); + + expect(request.body).toBe(prettyBody); + expect(request.contentLength).toBe(String(Buffer.byteLength(prettyBody))); + } finally { + httpAgent.destroy(); + } + }); + + test('fetch middleware can transform the body before the byte length is calculated', async () => { + const minifyingFetch: Fetch = async (url, init) => { + const body = typeof init?.body === 'string' ? JSON.stringify(JSON.parse(init.body)) : init?.body; + const nativeInit = { ...init, body } as unknown as Parameters[1]; + return (await globalThis.fetch(String(url), nativeInit)) as unknown as Response; + }; + + const request = await captureRequest({ fetch: minifyingFetch }); + const minifiedBody = JSON.stringify(JSON.parse(prettyBody)); + + expect(request.body).toBe(minifiedBody); + expect(request.contentLength).toBe(String(Buffer.byteLength(minifiedBody))); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts index 7639b23..82f5d0f 100644 --- a/tests/index.test.ts +++ b/tests/index.test.ts @@ -237,13 +237,23 @@ describe('request building', () => { const client = new Browserbase({ apiKey: 'My API Key' }); describe('Content-Length', () => { - test('handles multi-byte characters', async () => { + test('lets fetch handle multi-byte characters', async () => { const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: { value: '—' } }); - expect((req.headers as Record)['content-length']).toEqual('20'); + expect((req.headers as Record)['content-length']).toBeUndefined(); }); - test('handles standard characters', async () => { + test('lets fetch handle standard characters', async () => { const { req } = await client.buildRequest({ path: '/foo', method: 'post', body: { value: 'hello' } }); + expect((req.headers as Record)['content-length']).toBeUndefined(); + }); + + test('preserves a caller-provided value', async () => { + const { req } = await client.buildRequest({ + path: '/foo', + method: 'post', + body: { value: 'hello' }, + headers: { 'Content-Length': '22' }, + }); expect((req.headers as Record)['content-length']).toEqual('22'); }); }); From b7d7cd37e07fa8316b33c42430b0e4be1060d729 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Thu, 16 Jul 2026 13:49:41 -0700 Subject: [PATCH 2/2] test: make content length coverage type-portable --- tests/content-length.test.ts | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/content-length.test.ts b/tests/content-length.test.ts index abc5dd8..c0e5189 100644 --- a/tests/content-length.test.ts +++ b/tests/content-length.test.ts @@ -1,5 +1,5 @@ import Browserbase from '@browserbasehq/sdk'; -import type { Fetch, Response } from '@browserbasehq/sdk/core'; +import type { Fetch } from '@browserbasehq/sdk/core'; import http from 'node:http'; import type { AddressInfo } from 'node:net'; @@ -19,13 +19,14 @@ async function captureRequest( }); const server = http.createServer((request, response) => { - const chunks: Buffer[] = []; + const chunks: string[] = []; - request.on('data', (chunk) => chunks.push(Buffer.from(chunk))); + request.setEncoding('utf8'); + request.on('data', (chunk: string) => chunks.push(chunk)); request.on('error', rejectCapture); request.on('aborted', () => rejectCapture(new Error('request aborted before the body was received'))); request.on('end', () => { - const body = Buffer.concat(chunks).toString('utf8'); + const body = chunks.join(''); response.writeHead(200, { 'content-type': 'application/json' }); response.end('{}'); resolveCapture({ body, contentLength: request.headers['content-length'] }); @@ -80,10 +81,10 @@ describe('Content-Length on the wire', () => { }); test('fetch middleware can transform the body before the byte length is calculated', async () => { + const nativeFetch = (globalThis as unknown as { fetch: Fetch }).fetch; const minifyingFetch: Fetch = async (url, init) => { const body = typeof init?.body === 'string' ? JSON.stringify(JSON.parse(init.body)) : init?.body; - const nativeInit = { ...init, body } as unknown as Parameters[1]; - return (await globalThis.fetch(String(url), nativeInit)) as unknown as Response; + return nativeFetch(url, { ...init, body }); }; const request = await captureRequest({ fetch: minifyingFetch });