From 36ad4335e001672c47df2b5f70dde0c7ed418363 Mon Sep 17 00:00:00 2001 From: Florian Meyer Date: Sun, 9 Aug 2026 11:32:20 +0200 Subject: [PATCH] fix: distinguish native FormData in edge runtimes --- src/services/http/Transport.ts | 18 ++++++++++----- test/unit/httpTransport.spec.ts | 40 +++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 6 deletions(-) diff --git a/src/services/http/Transport.ts b/src/services/http/Transport.ts index e1509699d..58b4a5fb9 100644 --- a/src/services/http/Transport.ts +++ b/src/services/http/Transport.ts @@ -1,7 +1,6 @@ import * as http from 'http' import * as https from 'https' import { URLSearchParams } from 'url' -import FormData from 'form-data' type HeaderMap = Record type RequestUrl = string | URL @@ -11,6 +10,10 @@ interface PipeableBody { pipe(destination: NodeJS.WritableStream): NodeJS.WritableStream } +interface NodeFormDataBody extends PipeableBody { + getHeaders(): HeaderMap +} + export interface IHttpTransportOptions { method?: string headers?: HeaderMap @@ -28,8 +31,9 @@ export interface IBufferedResponse { export async function sendFetchRequest(url: RequestUrl, options: IHttpTransportOptions): Promise { // Native fetch supports WHATWG FormData, but this SDK's public upload contract // uses the npm form-data stream; without this path, multipart uploads are - // stringified as "[object FormData]" and break existing callers. - if (isFormDataBody(options.body)) { + // stringified as "[object FormData]" and break existing callers. Detect the + // stream contract because edge runtimes may shim the FormData constructor. + if (isNodeFormDataBody(options.body)) { const response = await sendNodeRequest(url, options) const responseBody = shouldOmitResponseBody(response.status) ? null : await response.binary() @@ -71,7 +75,7 @@ function buildRequestInit(options: IHttpTransportOptions): RequestInit & { duple } function normalizeHeaders(headers: HeaderMap | undefined, body: unknown): HeaderMap | undefined { - if (isFormDataBody(body)) { + if (isNodeFormDataBody(body)) { return mergeHeaders(headers, body.getHeaders()) } @@ -100,8 +104,10 @@ function mergeHeaders(headers: HeaderMap | undefined, nextHeaders: HeaderMap): H return mergedHeaders } -function isFormDataBody(body: unknown): body is FormData { - return body instanceof FormData +function isNodeFormDataBody(body: unknown): body is NodeFormDataBody { + return ( + isPipeableBody(body) && 'getHeaders' in body && typeof (body as { getHeaders?: unknown }).getHeaders === 'function' + ) } function isPipeableBody(body: unknown): body is PipeableBody { diff --git a/test/unit/httpTransport.spec.ts b/test/unit/httpTransport.spec.ts index e7366d3ff..473acd3e4 100644 --- a/test/unit/httpTransport.spec.ts +++ b/test/unit/httpTransport.spec.ts @@ -81,6 +81,46 @@ describe('HTTP transport', () => { } }) + it('supports WHATWG FormData when form-data is provided by a runtime shim', async () => { + let capturedRequest: ICapturedRequest | undefined + const server = await createServer(async (request: ICapturedRequest, response: http.ServerResponse) => { + capturedRequest = request + response.statusCode = 204 + response.end() + }) + const nativeFormData = new globalThis.FormData() + nativeFormData.append('field', 'value') + const originalHasInstance = Object.getOwnPropertyDescriptor(FormData, Symbol.hasInstance) + Object.defineProperty(FormData, Symbol.hasInstance, { + configurable: true, + value: (body: unknown) => body instanceof globalThis.FormData, + }) + + try { + const client = new Client() + const response = await client.apiRequest({ + method: 'POST', + overlapUrl: `${server.baseUrl}/upload`, + body: nativeFormData, + defaultJson: false, + }) + + expect(response.status).toBe(204) + expect(capturedRequest).toBeDefined() + const req = capturedRequest as ICapturedRequest + expect(String(req.headers['content-type'])).toContain('multipart/form-data; boundary=') + expect(req.body.toString()).toContain('name="field"') + expect(req.body.toString()).toContain('value') + } finally { + if (originalHasInstance) { + Object.defineProperty(FormData, Symbol.hasInstance, originalHasInstance) + } else { + Reflect.deleteProperty(FormData, Symbol.hasInstance) + } + await server.close() + } + }) + it('returns generated ResponseContext bodies through native fetch', async () => { let capturedRequest: ICapturedRequest | undefined const server = await createServer(async (request: ICapturedRequest, response: http.ServerResponse) => {