Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 12 additions & 6 deletions src/services/http/Transport.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>
type RequestUrl = string | URL
Expand All @@ -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
Expand All @@ -28,8 +31,9 @@ export interface IBufferedResponse {
export async function sendFetchRequest(url: RequestUrl, options: IHttpTransportOptions): Promise<Response> {
// 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()

Expand Down Expand Up @@ -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())
}

Expand Down Expand Up @@ -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 {
Expand Down
40 changes: 40 additions & 0 deletions test/unit/httpTransport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down