-
Notifications
You must be signed in to change notification settings - Fork 124
feat: add Bun runtime support #717
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -36,4 +36,27 @@ export class FormData extends _FormData { | |
|
|
||
| return contentDisposition; | ||
| } | ||
|
|
||
| /** | ||
| * Convert FormData to Buffer by consuming the CombinedStream. | ||
| * This is needed for Bun compatibility since Bun's undici | ||
| * doesn't support Node.js Stream objects as request body. | ||
| * | ||
| * Note: CombinedStream (which form-data extends) requires | ||
| * resume() to start data flow, unlike standard Readable streams. | ||
| */ | ||
| async toBuffer(): Promise<Buffer> { | ||
| return new Promise<Buffer>((resolve, reject) => { | ||
| const chunks: Buffer[] = []; | ||
| this.on('data', (chunk: Buffer | string) => { | ||
| // CombinedStream emits boundary/header strings alongside Buffer data | ||
| chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); | ||
| }); | ||
| this.on('end', () => resolve(Buffer.concat(chunks))); | ||
| this.on('error', reject); | ||
| // CombinedStream pauses by default and only starts | ||
| // flowing when piped or explicitly resumed | ||
| this.resume(); | ||
| }); | ||
|
Comment on lines
+48
to
+60
|
||
| } | ||
|
Comment on lines
+48
to
+61
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method buffers the entire |
||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -2,6 +2,7 @@ import diagnosticsChannel from 'node:diagnostics_channel'; | |||||||||||||||||||||||||||||||||||||
| import type { Channel } from 'node:diagnostics_channel'; | ||||||||||||||||||||||||||||||||||||||
| import { EventEmitter } from 'node:events'; | ||||||||||||||||||||||||||||||||||||||
| import { createReadStream } from 'node:fs'; | ||||||||||||||||||||||||||||||||||||||
| import { readFile } from 'node:fs/promises'; | ||||||||||||||||||||||||||||||||||||||
| import { STATUS_CODES } from 'node:http'; | ||||||||||||||||||||||||||||||||||||||
| import type { LookupFunction } from 'node:net'; | ||||||||||||||||||||||||||||||||||||||
| import { basename } from 'node:path'; | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -111,8 +112,18 @@ export type ClientOptions = { | |||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| export const VERSION: string = 'VERSION'; | ||||||||||||||||||||||||||||||||||||||
| export const isBun: boolean = !!process.versions.bun; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| function getRuntimeInfo(): string { | ||||||||||||||||||||||||||||||||||||||
| if (isBun) { | ||||||||||||||||||||||||||||||||||||||
| return `Bun/${process.versions.bun}`; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| return `Node.js/${process.version.substring(1)}`; | ||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| // 'node-urllib/4.0.0 Node.js/18.19.0 (darwin; x64)' | ||||||||||||||||||||||||||||||||||||||
| export const HEADER_USER_AGENT: string = `node-urllib/${VERSION} Node.js/${process.version.substring(1)} (${process.platform}; ${process.arch})`; | ||||||||||||||||||||||||||||||||||||||
| // 'node-urllib/4.0.0 Bun/1.2.5 (darwin; x64)' | ||||||||||||||||||||||||||||||||||||||
| export const HEADER_USER_AGENT: string = `node-urllib/${VERSION} ${getRuntimeInfo()} (${process.platform}; ${process.arch})`; | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+117
to
+126
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Keep the canonical This replaces the repo-wide As per coding guidelines, 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| function getFileName(stream: Readable): string { | ||||||||||||||||||||||||||||||||||||||
| const filePath: string = (stream as any).path; | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -427,16 +438,23 @@ export class HttpClient extends EventEmitter { | |||||||||||||||||||||||||||||||||||||
| let maxRedirects = args.maxRedirects ?? 10; | ||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||||||||||||||||
| // Bun's undici doesn't honor headersTimeout/bodyTimeout, | ||||||||||||||||||||||||||||||||||||||
| // use AbortSignal.timeout() as fallback | ||||||||||||||||||||||||||||||||||||||
| let requestSignal = args.signal; | ||||||||||||||||||||||||||||||||||||||
| if (isBun) { | ||||||||||||||||||||||||||||||||||||||
| const bunTimeoutSignal = AbortSignal.timeout(headersTimeout + bodyTimeout); | ||||||||||||||||||||||||||||||||||||||
| requestSignal = args.signal | ||||||||||||||||||||||||||||||||||||||
| ? AbortSignal.any([bunTimeoutSignal, args.signal]) | ||||||||||||||||||||||||||||||||||||||
| : bunTimeoutSignal; | ||||||||||||||||||||||||||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
set -e
echo "Bun timeout composition in src/HttpClient.ts:"
sed -n '443,448p' src/HttpClient.ts
echo
echo "RequestOptions.signal definition in src/Request.ts:"
rg -n -C2 '\bsignal\??:' src/Request.tsRepository: node-modules/urllib Length of output: 546
Line 447 passes 🧰 Tools🪛 GitHub Actions: Node.js old versions CI[error] 447-447: TypeScript (tsc) failed with TS2740: Type '{}' is missing required properties from type 'AbortSignal' (aborted, onabort, reason, throwIfAborted, and 3 more). Error location: AbortSignal.any([bunTimeoutSignal, args.signal]). 🪛 GitHub Actions: Publish Any Commit[error] 447-447: TypeScript (tshy) compile error TS2740: Type '{}' is missing the following properties from type 'AbortSignal': aborted, onabort, reason, throwIfAborted, and 3 more. Error at 'AbortSignal.any([bunTimeoutSignal, args.signal])'. 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||
| const requestOptions: IUndiciRequestOption = { | ||||||||||||||||||||||||||||||||||||||
| method, | ||||||||||||||||||||||||||||||||||||||
| // disable undici auto redirect handler | ||||||||||||||||||||||||||||||||||||||
| // maxRedirections: 0, | ||||||||||||||||||||||||||||||||||||||
| headersTimeout, | ||||||||||||||||||||||||||||||||||||||
| headers, | ||||||||||||||||||||||||||||||||||||||
| bodyTimeout, | ||||||||||||||||||||||||||||||||||||||
| opaque: internalOpaque, | ||||||||||||||||||||||||||||||||||||||
| dispatcher: args.dispatcher ?? this.#dispatcher, | ||||||||||||||||||||||||||||||||||||||
| signal: args.signal, | ||||||||||||||||||||||||||||||||||||||
| signal: requestSignal, | ||||||||||||||||||||||||||||||||||||||
| reset: false, | ||||||||||||||||||||||||||||||||||||||
| }; | ||||||||||||||||||||||||||||||||||||||
| if (typeof args.highWaterMark === 'number') { | ||||||||||||||||||||||||||||||||||||||
|
|
@@ -500,14 +518,24 @@ export class HttpClient extends EventEmitter { | |||||||||||||||||||||||||||||||||||||
| let value: any; | ||||||||||||||||||||||||||||||||||||||
| if (typeof file === 'string') { | ||||||||||||||||||||||||||||||||||||||
| fileName = basename(file); | ||||||||||||||||||||||||||||||||||||||
| value = createReadStream(file); | ||||||||||||||||||||||||||||||||||||||
| // Bun's CombinedStream can't pipe file streams | ||||||||||||||||||||||||||||||||||||||
| value = isBun ? await readFile(file) : createReadStream(file); | ||||||||||||||||||||||||||||||||||||||
| } else if (Buffer.isBuffer(file)) { | ||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+519
to
521
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please don't silently materialize every Bun multipart upload in memory. This path reads file inputs with Based on learnings, urllib is built as a Node.js HTTP client library on top of undici with features including basic/digest authentication, redirections, timeout handling, gzip/brotli compression, file uploads, and HTTP/2 support. Also applies to: 528-535, 548-550 🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||
| fileName = customFileName || `bufferfile${index}`; | ||||||||||||||||||||||||||||||||||||||
| value = file; | ||||||||||||||||||||||||||||||||||||||
| } else if (file instanceof Readable || isReadable(file as any)) { | ||||||||||||||||||||||||||||||||||||||
| fileName = getFileName(file) || customFileName || `streamfile${index}`; | ||||||||||||||||||||||||||||||||||||||
| isStreamingRequest = true; | ||||||||||||||||||||||||||||||||||||||
| value = file; | ||||||||||||||||||||||||||||||||||||||
| if (isBun) { | ||||||||||||||||||||||||||||||||||||||
| // Bun's CombinedStream can't pipe Node.js streams | ||||||||||||||||||||||||||||||||||||||
| const streamChunks: Buffer[] = []; | ||||||||||||||||||||||||||||||||||||||
| for await (const chunk of file) { | ||||||||||||||||||||||||||||||||||||||
|
||||||||||||||||||||||||||||||||||||||
| for await (const chunk of file) { | |
| const sourceStream: any = | |
| typeof (file as any)[Symbol.asyncIterator] === 'function' | |
| ? file | |
| : new Readable({ read() {} }).wrap(file as any); | |
| for await (const chunk of sourceStream) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Buffering Bun FormData needs to copy the multipart headers too.
Unlike the args.files branch, this path turns args.content into a bare Buffer without copying getHeaders(). Once the body is just bytes, the multipart boundary metadata is gone unless content-type is carried over here as well.
🐛 Proposed fix
if (isBun && args.content instanceof FormData) {
- requestOptions.body = await (args.content as FormData).toBuffer();
+ const formHeaders = args.content.getHeaders();
+ if (!headers['content-type'] && formHeaders['content-type']) {
+ headers['content-type'] = formHeaders['content-type'];
+ }
+ requestOptions.body = await args.content.toBuffer();
} else {📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| if (isBun && args.content instanceof FormData) { | |
| requestOptions.body = await (args.content as FormData).toBuffer(); | |
| } else { | |
| requestOptions.body = args.content; | |
| } | |
| if (args.contentType) { | |
| headers['content-type'] = args.contentType; | |
| if (isBun && args.content instanceof FormData) { | |
| const formHeaders = args.content.getHeaders(); | |
| if (!headers['content-type'] && formHeaders['content-type']) { | |
| headers['content-type'] = formHeaders['content-type']; | |
| } | |
| requestOptions.body = await args.content.toBuffer(); | |
| } else { | |
| requestOptions.body = args.content; | |
| } | |
| if (args.contentType) { | |
| headers['content-type'] = args.contentType; |
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/HttpClient.ts` around lines 557 - 563, When running in Bun and
args.content is a FormData, the code currently buffers it to a Buffer but
forgets to copy multipart headers (boundary) from FormData.getHeaders(), so the
content-type/boundary is lost; update the isBun branch where args.content is
handled (look for isBun, args.content, requestOptions.body and FormData casting)
to call (args.content as FormData).getHeaders() and merge its content-type into
the headers map before/after setting requestOptions.body so the multipart
boundary is preserved (ensure headers['content-type'] is set from getHeaders()
when present).
Copilot
AI
Mar 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On Bun, args.content can still be a Node.js Readable (you later convert requestOptions.body via Readable.toWeb). However isStreamingRequest is currently forced to false on Bun (!isBun && isReadable(args.content)), which means retries/socketErrorRetry/redirects may stay enabled for a non-rewindable streaming body. That can break retries/redirect handling (subsequent attempts will re-use an already-consumed stream). Consider marking isStreamingRequest whenever the body is stream-like regardless of runtime (and disabling retry/redirect accordingly), even if Bun requires a Web stream conversion.
| isStreamingRequest = !isBun && isReadable(args.content); | |
| isStreamingRequest = isReadable(args.content); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Bun content streams still need the streaming retry/redirect guard.
Line 567 forces Bun args.content streams to look replayable, but Lines 620-621 still send them as a one-shot web stream. That means Lines 609-613 never disable redirect/socketErrorRetry for this path, so a 30x or retry can end up reusing a consumed body from options.content.
🔒 Proposed fix
- isStreamingRequest = !isBun && isReadable(args.content);
+ isStreamingRequest = isReadable(args.content);Also applies to: 609-613, 620-622
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/HttpClient.ts` at line 567, The code treats Bun stream bodies as
replayable by setting isStreamingRequest = !isBun && isReadable(args.content);
instead, detect readable streams regardless of isBun and ensure you disable
redirect and socketErrorRetry for those cases: update the isStreamingRequest
logic to consider isReadable(args.content) even when isBun, and in the
request-paths (the blocks referencing redirect and socketErrorRetry around where
options.content/args.content are used) explicitly turn off redirect and
socketErrorRetry when args.content or options.content is a readable stream so
streaming requests are not retried or redirected.
Copilot
AI
Mar 28, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The Bun body conversion only runs when requestOptions.body instanceof Readable. For “old-style” streams that satisfy isReadable(...) but are not instanceof Readable (which this codebase already supports elsewhere via isReadable/Readable().wrap(...)), Bun will still receive a Node stream and likely fail. Consider changing this guard to use isReadable(requestOptions.body) and wrapping non-Readable streams before calling Readable.toWeb(...).
| if (isBun && requestOptions.body instanceof Readable) { | |
| requestOptions.body = Readable.toWeb(requestOptions.body) as any; | |
| if (isBun && isReadable(requestOptions.body)) { | |
| const nodeReadable = | |
| requestOptions.body instanceof Readable | |
| ? requestOptions.body | |
| : new Readable({ read() {} }).wrap(requestOptions.body as any); | |
| requestOptions.body = Readable.toWeb(nodeReadable) as any; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The timeout value passed to HttpClientRequestTimeoutError is headersTimeout || bodyTimeout, which can be misleading. For Bun, you are creating a single timeout of headersTimeout + bodyTimeout. The error message should reflect this total timeout duration to avoid confusion. For example, if headersTimeout is 400 and bodyTimeout is 500, the actual timeout is 900ms, but the error will state 400ms.
I suggest using headersTimeout + bodyTimeout here to be consistent.
Note that this will require updating the corresponding test in test/options.timeout.test.ts to expect the correct total timeout value.
| err = new HttpClientRequestTimeoutError(headersTimeout || bodyTimeout, { cause: err }); | |
| } else if (isBun && err.name === 'TypeError' && /timed?\s*out|timeout/i.test(err.message)) { | |
| // Bun may wrap timeout as TypeError | |
| err = new HttpClientRequestTimeoutError(headersTimeout || bodyTimeout, { cause: err }); | |
| err = new HttpClientRequestTimeoutError(headersTimeout + bodyTimeout, { cause: err }); | |
| } else if (isBun && err.name === 'TypeError' && /timed?\s*out|timeout/i.test(err.message)) { | |
| // Bun may wrap timeout as TypeError | |
| err = new HttpClientRequestTimeoutError(headersTimeout + bodyTimeout, { cause: err }); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Report the timeout Bun actually enforced.
Line 818 wraps Bun timeouts with headersTimeout || bodyTimeout, but the fallback signal above aborts after headersTimeout + bodyTimeout. With split values, callers will see the wrong timeout in HttpClientRequestTimeoutError.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@src/HttpClient.ts` around lines 816 - 821, The Bun timeout branches (checks
on isBun and err.name) currently construct HttpClientRequestTimeoutError using
headersTimeout || bodyTimeout, which hides the actual timeout when the code uses
the fallback abort signal that fires after headersTimeout + bodyTimeout; update
those branches to compute and pass the real enforced timeout (e.g., use
headersTimeout + bodyTimeout when both are set, otherwise use the one that
exists) into HttpClientRequestTimeoutError so the error reports the correct
timeout value (refer to isBun, the err.name checks,
HttpClientRequestTimeoutError, headersTimeout, and bodyTimeout to locate the
changes).
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -243,7 +243,13 @@ export class FetchFactory { | |||||||||||||||||||||||
| } | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| // get undici internal response | ||||||||||||||||||||||||
| const state = getResponseState(res!); | ||||||||||||||||||||||||
| // Bun's Response doesn't have the same internal state as npm undici's Response | ||||||||||||||||||||||||
| let state: any; | ||||||||||||||||||||||||
| try { | ||||||||||||||||||||||||
| state = getResponseState(res!); | ||||||||||||||||||||||||
| } catch { | ||||||||||||||||||||||||
| state = {}; | ||||||||||||||||||||||||
|
Comment on lines
+250
to
+251
|
||||||||||||||||||||||||
| } catch { | |
| state = {}; | |
| } catch (err) { | |
| // Only fall back silently on Bun, where getResponseState is expected to fail. | |
| const isBun = typeof (globalThis as any).Bun !== 'undefined'; | |
| if (isBun) { | |
| state = {}; | |
| } else { | |
| debug('getResponseState failed unexpectedly: %o', err); | |
| throw err; | |
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
FormData.toBuffer()attachesdata/end/errorlisteners with.on(...)but never removes them. If the same FormData instance is re-used ortoBuffer()is called more than once, listeners can accumulate and cause leaks or duplicate resolution attempts. Preferonce(...)handlers and/or explicitly remove listeners in both resolve and reject paths.